diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9fb5c6b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.superpowers +.worktrees +**/dist +**/node_modules +apps/desktop/src-tauri/target +docs +tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3796bd1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,148 @@ +name: CI + +"on": + pull_request: + push: + branches: [main, master] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + typescript-quality: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test:unit + - run: pnpm build + - run: pnpm test:release + + npm-tarball-smoke: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter folder-structure-sync test:pack + + api-image-smoke: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:api-image + + postgres-integration: + runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: rootline + POSTGRES_PASSWORD: rootline + POSTGRES_DB: rootline_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U rootline -d rootline_test" + --health-interval 2s + --health-timeout 5s + --health-retries 30 + env: + DATABASE_URL: postgresql://rootline:rootline@127.0.0.1:5432/rootline_test?schema=public + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Install Tauri system dependencies for the native PostgreSQL seam + run: sudo apt-get update && sudo apt-get install --yes libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - run: pnpm build:workspace-deps + - run: pnpm --filter @rootline/api prisma:generate + - run: pnpm --filter @rootline/api prisma:migrate:deploy + - run: pnpm --filter @rootline/api test:e2e + + rust: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Install Tauri system dependencies + run: sudo apt-get update && sudo apt-get install --yes libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: cargo fmt --check --manifest-path apps/desktop/src-tauri/Cargo.toml + - run: cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets --all-features -- -D warnings + - run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml + + tauri-build: + name: Tauri ${{ matrix.label }} + strategy: + fail-fast: false + matrix: + include: + - label: macOS Universal + runner: macos-15 + target: universal-apple-darwin + - label: Windows x64 + runner: windows-2025 + target: x86_64-pc-windows-msvc + - label: Windows ARM64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: ${{ matrix.target == 'universal-apple-darwin' && 'aarch64-apple-darwin,x86_64-apple-darwin' || matrix.target }} + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Run Windows filesystem adapter tests + if: runner.os == 'Windows' && matrix.target == 'x86_64-pc-windows-msvc' + shell: pwsh + run: | + pnpm --filter folder-structure-sync test + if ($LASTEXITCODE -ne 0) { throw "Node filesystem adapter tests failed on Windows." } + cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml + if ($LASTEXITCODE -ne 0) { throw "Rust filesystem adapter tests failed on Windows." } + - run: pnpm --filter @rootline/desktop tauri build --target ${{ matrix.target }} --no-bundle diff --git a/.github/workflows/release-api.yml b/.github/workflows/release-api.yml new file mode 100644 index 0000000..d15a68b --- /dev/null +++ b/.github/workflows/release-api.yml @@ -0,0 +1,231 @@ +name: Release Rootline API 2.0.0 + +"on": + workflow_dispatch: + inputs: + confirmation: + description: Type release-api-v2.0.0 to deploy the stable API + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-api-2.0.0 + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-24.04 + environment: api-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high + - name: Require exact version and every production deployment secret + env: + CONFIRMATION: ${{ inputs.confirmation }} + ROOTLINE_API_DATABASE_URL: ${{ secrets.ROOTLINE_API_DATABASE_URL }} + ROOTLINE_API_DEPLOY_WEBHOOK_URL: ${{ secrets.ROOTLINE_API_DEPLOY_WEBHOOK_URL }} + ROOTLINE_API_DEPLOY_TOKEN: ${{ secrets.ROOTLINE_API_DEPLOY_TOKEN }} + ROOTLINE_API_BASE_URL: ${{ secrets.ROOTLINE_API_BASE_URL }} + ROOTLINE_JWT_ISSUER: ${{ secrets.ROOTLINE_JWT_ISSUER }} + ROOTLINE_JWT_AUDIENCE: ${{ secrets.ROOTLINE_JWT_AUDIENCE }} + ROOTLINE_JWT_JWKS_B64: ${{ secrets.ROOTLINE_JWT_JWKS_B64 }} + run: | + set -eu + if [ "${GITHUB_REF}" != "refs/tags/v2.0.0" ]; then + echo "::error::Stable API release is restricted to the existing tag refs/tags/v2.0.0." + exit 1 + fi + if [ "${CONFIRMATION}" != "release-api-v2.0.0" ]; then + echo "::error::Stable API release blocked. Re-run with confirmation=release-api-v2.0.0." + exit 1 + fi + missing="" + for name in ROOTLINE_API_DATABASE_URL ROOTLINE_API_DEPLOY_WEBHOOK_URL ROOTLINE_API_DEPLOY_TOKEN ROOTLINE_API_BASE_URL ROOTLINE_JWT_ISSUER ROOTLINE_JWT_AUDIENCE ROOTLINE_JWT_JWKS_B64; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + echo "::error::Stable API release blocked. Configure repository environment secrets:$missing" + exit 1 + fi + case "${ROOTLINE_API_BASE_URL}" in https://*) ;; *) echo "::error::ROOTLINE_API_BASE_URL must use HTTPS."; exit 1 ;; esac + case "${ROOTLINE_API_DEPLOY_WEBHOOK_URL}" in https://*) ;; *) echo "::error::ROOTLINE_API_DEPLOY_WEBHOOK_URL must use HTTPS."; exit 1 ;; esac + node <<'NODE' + const database = new URL(process.env.ROOTLINE_API_DATABASE_URL); + if (!["postgres:", "postgresql:"].includes(database.protocol)) throw new Error("ROOTLINE_API_DATABASE_URL must be PostgreSQL."); + if (database.searchParams.get("sslmode") !== "require" || database.searchParams.get("sslaccept") !== "strict") { + throw new Error("ROOTLINE_API_DATABASE_URL must verify PostgreSQL TLS with sslmode=require&sslaccept=strict."); + } + if (new URL(process.env.ROOTLINE_JWT_ISSUER).protocol !== "https:") throw new Error("ROOTLINE_JWT_ISSUER must use HTTPS."); + const jwks = JSON.parse(Buffer.from(process.env.ROOTLINE_JWT_JWKS_B64, "base64").toString("utf8")); + if (!Array.isArray(jwks.keys) || !jwks.keys.some((key) => key?.kty === "RSA")) { + throw new Error("ROOTLINE_JWT_JWKS_B64 must decode to a JWKS with an RSA public key."); + } + NODE + node -e "const p=require('./apps/api/package.json'); if(p.version !== '2.0.0') throw new Error('apps/api/package.json must be version 2.0.0')" + + image: + needs: preflight + runs-on: ubuntu-24.04 + environment: api-production + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.immutable.outputs.image }} + build_id: ${{ steps.identity.outputs.build_id }} + stable_image: ${{ steps.image.outputs.stable_image }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:api-image + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: identity + run: echo "build_id=rootline-2.0.0-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + - id: image + run: | + repository="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/rootline-api" + echo "image=${repository}:candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + echo "stable_image=${repository}:2.0.0" >> "$GITHUB_OUTPUT" + - name: Refuse to overwrite stable image tag + env: + IMAGE: ${{ steps.image.outputs.stable_image }} + run: | + set +e + inspection=$(docker buildx imagetools inspect "$IMAGE" 2>&1) + inspection_status=$? + set -e + if [ "$inspection_status" -eq 0 ]; then + echo "::error::Stable API image ${IMAGE} already exists. Published 2.0.0 bytes are immutable; release a new version." + exit 1 + fi + case "$inspection" in + *"not found"*|*"manifest unknown"*) ;; + *) echo "::error::Could not prove stable API tag ${IMAGE} is unused; refusing to push."; printf '%s\n' "$inspection"; exit 1 ;; + esac + - id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: apps/api/Dockerfile + push: true + tags: ${{ steps.image.outputs.image }} + build-args: ROOTLINE_BUILD_ID=${{ steps.identity.outputs.build_id }} + - id: immutable + name: Resolve immutable image reference + env: + IMAGE: ${{ steps.image.outputs.image }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + repository=${IMAGE%:*} + echo "image=${repository}@${DIGEST}" >> "$GITHUB_OUTPUT" + + migrate: + needs: [preflight, image] + runs-on: ubuntu-24.04 + environment: api-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Apply checked-in production migrations + env: + DATABASE_URL: ${{ secrets.ROOTLINE_API_DATABASE_URL }} + run: pnpm --filter @rootline/api exec prisma migrate deploy + + deploy: + needs: [preflight, image, migrate] + runs-on: ubuntu-24.04 + environment: api-production + steps: + - name: Request deployment of the immutable image + env: + IMAGE: ${{ needs.image.outputs.image }} + ROOTLINE_BUILD_ID: ${{ needs.image.outputs.build_id }} + DEPLOY_URL: ${{ secrets.ROOTLINE_API_DEPLOY_WEBHOOK_URL }} + DEPLOY_TOKEN: ${{ secrets.ROOTLINE_API_DEPLOY_TOKEN }} + run: | + curl --fail-with-body --silent --show-error --retry 3 -X POST \ + -H "Authorization: Bearer ${DEPLOY_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"image\":\"${IMAGE}\",\"buildId\":\"${ROOTLINE_BUILD_ID}\"}" \ + "${DEPLOY_URL}" + + health: + needs: [preflight, image, deploy] + runs-on: ubuntu-24.04 + environment: api-production + steps: + - name: Require deployed API health + env: + ROOTLINE_API_BASE_URL: ${{ secrets.ROOTLINE_API_BASE_URL }} + EXPECTED_BUILD_ID: ${{ needs.image.outputs.build_id }} + run: | + set -eu + for attempt in $(seq 1 30); do + echo "Health check attempt ${attempt}/30" + body=$(curl --fail --silent --show-error --max-time 10 "${ROOTLINE_API_BASE_URL%/}/healthz" || true) + printf '%s' "$body" | jq -e '.status == "ok" and .buildId == env.EXPECTED_BUILD_ID' >/dev/null && exit 0 + sleep 10 + done + echo "::error::Stable API release blocked: /healthz did not report the requested build identity ${EXPECTED_BUILD_ID}." + exit 1 + + promote: + needs: [image, health] + runs-on: ubuntu-24.04 + environment: api-production + permissions: + contents: read + packages: write + steps: + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Recheck stable tag before promotion + env: + STABLE_IMAGE: ${{ needs.image.outputs.stable_image }} + run: | + set +e + inspection=$(docker buildx imagetools inspect "$STABLE_IMAGE" 2>&1) + inspection_status=$? + set -e + if [ "$inspection_status" -eq 0 ]; then + echo "::error::Stable API image ${STABLE_IMAGE} already exists. Refusing to overwrite it." + exit 1 + fi + case "$inspection" in + *"not found"*|*"manifest unknown"*) ;; + *) echo "::error::Could not prove stable API tag ${STABLE_IMAGE} is unused; refusing promotion."; printf '%s\n' "$inspection"; exit 1 ;; + esac + - name: Promote the verified digest to the stable tag + env: + SOURCE_IMAGE: ${{ needs.image.outputs.image }} + STABLE_IMAGE: ${{ needs.image.outputs.stable_image }} + run: docker buildx imagetools create --tag "$STABLE_IMAGE" "$SOURCE_IMAGE" diff --git a/.github/workflows/release-desktop-candidate.yml b/.github/workflows/release-desktop-candidate.yml new file mode 100644 index 0000000..6d0c6b8 --- /dev/null +++ b/.github/workflows/release-desktop-candidate.yml @@ -0,0 +1,292 @@ +name: Build Rootline Desktop Candidate + +"on": + workflow_dispatch: + inputs: + channel: + description: Keep artifacts internal or publish a GitHub prerelease + required: true + default: internal + type: choice + options: + - internal + - public-beta + beta_tag: + description: Public beta tag in the form v2.0.0-beta.N + required: false + default: v2.0.0-beta.1 + type: string + confirmation: + description: Type build-rootline-candidate + required: true + type: string + +permissions: + contents: read + +concurrency: + group: desktop-candidate-${{ github.ref }} + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-24.04 + environment: desktop-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high + - name: Install minisign for updater key-pair proof + run: sudo apt-get update && sudo apt-get install --yes minisign + - name: Require reviewed master, confirmation, signing credentials, and hosted endpoints + env: + CHANNEL: ${{ inputs.channel }} + BETA_TAG: ${{ inputs.beta_tag }} + CONFIRMATION: ${{ inputs.confirmation }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + run: | + set -eu + if [ "${GITHUB_REF}" != "refs/heads/master" ]; then + echo "::error::Signed candidates must be built from reviewed master." + exit 1 + fi + if [ "${CONFIRMATION}" != "build-rootline-candidate" ]; then + echo "::error::Candidate build blocked. Use confirmation=build-rootline-candidate." + exit 1 + fi + if [ "${CHANNEL}" = "public-beta" ] && ! printf '%s' "${BETA_TAG}" | grep -Eq '^v2[.]0[.]0-beta[.][0-9]+$'; then + echo "::error::Public beta tag must match v2.0.0-beta.N." + exit 1 + fi + missing="" + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID APPLE_KEYCHAIN_PASSWORD WINDOWS_CERTIFICATE WINDOWS_CERTIFICATE_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD TAURI_UPDATER_PUBLIC_KEY VITE_AUTHENTIK_ISSUER VITE_AUTHENTIK_CLIENT_ID VITE_ROOTLINE_SYNC_API; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + echo "::error::Signed candidate blocked. Configure desktop-production secrets/variables:$missing" + exit 1 + fi + case "${VITE_AUTHENTIK_ISSUER}" in https://*) ;; *) echo "::error::VITE_AUTHENTIK_ISSUER must use HTTPS."; exit 1 ;; esac + case "${VITE_ROOTLINE_SYNC_API}" in https://*) ;; *) echo "::error::VITE_ROOTLINE_SYNC_API must use HTTPS."; exit 1 ;; esac + node -e "const p=require('./apps/desktop/package.json'); if(p.version !== '2.0.0') throw new Error('apps/desktop/package.json must be version 2.0.0')" + - name: Prove updater private key, password, and public key match + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + run: | + set -eu + challenge="$RUNNER_TEMP/rootline-candidate-key-pair-challenge" + signature="$challenge.sig" + public_key="$RUNNER_TEMP/rootline-updater-public.key" + minisign_signature="$RUNNER_TEMP/rootline-candidate.minisig" + printf '%s\n' "rootline-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" > "$challenge" + if ! pnpm --filter @rootline/desktop tauri signer sign "$challenge" >/dev/null; then + echo "::error::Candidate blocked: TAURI_SIGNING_PRIVATE_KEY or its password is invalid." + exit 1 + fi + if ! printf '%s' "$TAURI_UPDATER_PUBLIC_KEY" | base64 --decode > "$public_key"; then + echo "::error::Candidate blocked: TAURI_UPDATER_PUBLIC_KEY is not valid base64." + exit 1 + fi + if ! base64 --decode < "$signature" > "$minisign_signature"; then + echo "::error::Candidate blocked: Tauri produced an invalid updater signature." + exit 1 + fi + if ! minisign -Vm "$challenge" -p "$public_key" -x "$minisign_signature" >/dev/null; then + echo "::error::Candidate blocked: private key, password, and public key do not form one updater keypair." + exit 1 + fi + + macos-universal: + needs: preflight + runs-on: macos-15 + environment: desktop-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Import Developer ID certificate into an isolated keychain + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: | + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/rootline.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security default-keychain -s "$RUNNER_TEMP/rootline.keychain-db" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security set-keychain-settings -t 3600 -u "$RUNNER_TEMP/rootline.keychain-db" + security import "$RUNNER_TEMP/rootline.p12" -k "$RUNNER_TEMP/rootline.keychain-db" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security find-identity -v -p codesigning "$RUNNER_TEMP/rootline.keychain-db" | grep -F "$APPLE_SIGNING_IDENTITY" + - name: Build, sign, notarize, and staple macOS Universal candidate + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + CHANNEL: ${{ inputs.channel }} + BETA_TAG: ${{ inputs.beta_tag }} + run: | + CANDIDATE_VERSION="2.0.0" + if [ "$CHANNEL" = "public-beta" ]; then CANDIDATE_VERSION="${BETA_TAG#v}"; fi + export CANDIDATE_VERSION + node -e 'require("node:fs").writeFileSync(process.env.RUNNER_TEMP + "/updater-config.json", JSON.stringify({version:process.env.CANDIDATE_VERSION,plugins:{updater:{pubkey:process.env.TAURI_UPDATER_PUBLIC_KEY}}}))' + pnpm --filter @rootline/desktop tauri build --target universal-apple-darwin --bundles app,dmg --config "$RUNNER_TEMP/updater-config.json" + - name: Verify and stage signed candidate + run: | + set -eu + bundle=apps/desktop/src-tauri/target/universal-apple-darwin/release/bundle + app=$(find "$bundle/macos" -maxdepth 1 -type d -name '*.app' -print -quit) + dmg=$(find "$bundle/dmg" -type f -name '*.dmg' -print -quit) + updater=$(find "$bundle" -type f -name '*.app.tar.gz' -print -quit) + [ -n "$app" ] && [ -n "$dmg" ] && [ -n "$updater" ] && [ -s "$updater.sig" ] + codesign --verify --deep --strict --verbose=2 "$app" + xcrun stapler validate "$dmg" + mkdir release-assets + cp "$dmg" release-assets/rootline-2.0.0-candidate-darwin-universal.dmg + cp "$updater" release-assets/rootline-2.0.0-candidate-darwin-universal.app.tar.gz + cp "$updater.sig" release-assets/rootline-2.0.0-candidate-darwin-universal.app.tar.gz.sig + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-candidate-darwin-universal + path: release-assets + if-no-files-found: error + + windows: + needs: preflight + environment: desktop-production + strategy: + fail-fast: false + matrix: + include: + - platform: windows-x86_64 + runner: windows-2025 + target: x86_64-pc-windows-msvc + - platform: windows-aarch64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Import Windows Authenticode certificate + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $certificatePath = Join-Path $env:RUNNER_TEMP "rootline.pfx" + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $certificate = Import-PfxCertificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\My -Password $password + if (-not $certificate.Thumbprint) { throw "Imported Windows certificate has no thumbprint." } + "WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" >> $env:GITHUB_ENV + - name: Build and Authenticode-sign Windows candidate + shell: pwsh + env: + TAURI_TARGET: ${{ matrix.target }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + CHANNEL: ${{ inputs.channel }} + BETA_TAG: ${{ inputs.beta_tag }} + run: | + $version = "2.0.0" + if ($env:CHANNEL -eq "public-beta") { $version = $env:BETA_TAG.Substring(1) } + $config = @{ version = $version; bundle = @{ windows = @{ certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT; digestAlgorithm = "sha256"; timestampUrl = "http://timestamp.digicert.com" } }; plugins = @{ updater = @{ pubkey = $env:TAURI_UPDATER_PUBLIC_KEY } } } | ConvertTo-Json -Compress -Depth 5 + pnpm --filter @rootline/desktop tauri build --target $env:TAURI_TARGET --bundles nsis --config $config + if ($LASTEXITCODE -ne 0) { throw "Tauri Windows candidate build failed." } + - name: Verify and stage signed candidate + shell: pwsh + env: + PLATFORM: ${{ matrix.platform }} + TAURI_TARGET: ${{ matrix.target }} + run: | + $bundle = "apps/desktop/src-tauri/target/$env:TAURI_TARGET/release/bundle" + $installer = Get-ChildItem -Path $bundle -Recurse -File -Filter "*.exe" | Select-Object -First 1 + if (-not $installer -or -not (Test-Path "$($installer.FullName).sig")) { throw "Signed candidate artifacts are incomplete." } + $authenticode = Get-AuthenticodeSignature $installer.FullName + if ($authenticode.Status -ne "Valid") { throw "Installer Authenticode status is $($authenticode.Status)." } + New-Item -ItemType Directory -Path release-assets | Out-Null + Copy-Item $installer.FullName "release-assets/rootline-2.0.0-candidate-$env:PLATFORM-setup.exe" + Copy-Item "$($installer.FullName).sig" "release-assets/rootline-2.0.0-candidate-$env:PLATFORM-setup.exe.sig" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-candidate-${{ matrix.platform }} + path: release-assets + if-no-files-found: error + + publish-beta: + if: inputs.channel == 'public-beta' + needs: [preflight, macos-universal, windows] + runs-on: ubuntu-24.04 + environment: desktop-production + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: rootline-candidate-* + path: release-assets + merge-multiple: true + - name: Publish signed direct-download beta without touching the stable updater channel + env: + BETA_TAG: ${{ inputs.beta_tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + printf '%s\n' 'Rootline by baole.space signed beta. This prerelease does not publish latest.json; the installed beta can upgrade through the stable channel after v2.0.0 is released.' > release-notes.md + gh release create "$BETA_TAG" release-assets/* --prerelease --target "$GITHUB_SHA" --title "Rootline by baole.space ${BETA_TAG}" --notes-file release-notes.md diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 0000000..be3cbd5 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,268 @@ +name: Release Rootline Desktop 2.0.0 + +"on": + push: + tags: ["v2.0.0"] + workflow_dispatch: + inputs: + confirmation: + description: Type release-desktop-v2.0.0 while running from tag v2.0.0 + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-desktop-2.0.0 + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-24.04 + environment: desktop-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high + - name: Install minisign for updater key-pair proof + run: sudo apt-get update && sudo apt-get install --yes minisign + - name: Require stable tag, release confirmation, signing credentials, and hosted profile endpoints + env: + CONFIRMATION: ${{ inputs.confirmation }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + run: | + set -eu + if [ "${GITHUB_REF}" != "refs/tags/v2.0.0" ]; then + echo "::error::Stable desktop release is restricted to the existing tag refs/tags/v2.0.0." + exit 1 + fi + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${CONFIRMATION}" != "release-desktop-v2.0.0" ]; then + echo "::error::Stable desktop release blocked. Re-run from tag v2.0.0 with confirmation=release-desktop-v2.0.0." + exit 1 + fi + missing="" + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID APPLE_KEYCHAIN_PASSWORD WINDOWS_CERTIFICATE WINDOWS_CERTIFICATE_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD TAURI_UPDATER_PUBLIC_KEY VITE_AUTHENTIK_ISSUER VITE_AUTHENTIK_CLIENT_ID VITE_ROOTLINE_SYNC_API; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + echo "::error::Stable signed desktop release blocked. Configure repository environment secrets/variables:$missing" + exit 1 + fi + case "${VITE_AUTHENTIK_ISSUER}" in https://*) ;; *) echo "::error::VITE_AUTHENTIK_ISSUER must use HTTPS."; exit 1 ;; esac + case "${VITE_ROOTLINE_SYNC_API}" in https://*) ;; *) echo "::error::VITE_ROOTLINE_SYNC_API must use HTTPS."; exit 1 ;; esac + node -e "const p=require('./apps/desktop/package.json'); if(p.version !== '2.0.0') throw new Error('apps/desktop/package.json must be version 2.0.0')" + - name: Prove updater private key, password, and public key match + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + run: | + set -eu + challenge="$RUNNER_TEMP/rootline-updater-key-pair-challenge" + signature="$challenge.sig" + public_key="$RUNNER_TEMP/rootline-updater-public.key" + minisign_signature="$RUNNER_TEMP/rootline-updater-challenge.minisig" + printf '%s\n' "rootline-updater-key-pair-v2.0.0-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" > "$challenge" + if ! pnpm --filter @rootline/desktop tauri signer sign "$challenge" >/dev/null; then + echo "::error::Stable desktop release blocked: TAURI_SIGNING_PRIVATE_KEY or its password is invalid." + exit 1 + fi + if ! printf '%s' "$TAURI_UPDATER_PUBLIC_KEY" | base64 --decode > "$public_key"; then + echo "::error::Stable desktop release blocked: TAURI_UPDATER_PUBLIC_KEY is not valid base64." + exit 1 + fi + if ! base64 --decode < "$signature" > "$minisign_signature"; then + echo "::error::Stable desktop release blocked: Tauri produced an invalid updater signature." + exit 1 + fi + if ! minisign -Vm "$challenge" -p "$public_key" -x "$minisign_signature" >/dev/null; then + echo "::error::Stable desktop release blocked: private key, password, and public key do not form one updater keypair." + exit 1 + fi + + macos-universal: + needs: preflight + runs-on: macos-15 + environment: desktop-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Import Developer ID certificate into an isolated keychain + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: | + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/rootline.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security default-keychain -s "$RUNNER_TEMP/rootline.keychain-db" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security set-keychain-settings -t 3600 -u "$RUNNER_TEMP/rootline.keychain-db" + security import "$RUNNER_TEMP/rootline.p12" -k "$RUNNER_TEMP/rootline.keychain-db" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security find-identity -v -p codesigning "$RUNNER_TEMP/rootline.keychain-db" | grep -F "$APPLE_SIGNING_IDENTITY" + - name: Build, sign, notarize, and staple macOS Universal artifacts + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + run: | + node -e 'require("node:fs").writeFileSync(process.env.RUNNER_TEMP + "/updater-config.json", JSON.stringify({plugins:{updater:{pubkey:process.env.TAURI_UPDATER_PUBLIC_KEY}}}))' + pnpm --filter @rootline/desktop tauri build --target universal-apple-darwin --bundles app,dmg --config "$RUNNER_TEMP/updater-config.json" + - name: Verify and stage signed updater and installer + run: | + set -eu + bundle=apps/desktop/src-tauri/target/universal-apple-darwin/release/bundle + app=$(find "$bundle/macos" -maxdepth 1 -type d -name '*.app' -print -quit) + dmg=$(find "$bundle/dmg" -type f -name '*.dmg' -print -quit) + updater=$(find "$bundle" -type f -name '*.app.tar.gz' -print -quit) + [ -n "$app" ] && [ -n "$dmg" ] && [ -n "$updater" ] && [ -s "$updater.sig" ] + codesign --verify --deep --strict --verbose=2 "$app" + xcrun stapler validate "$dmg" + mkdir release-assets + cp "$dmg" release-assets/rootline-2.0.0-darwin-universal.dmg + cp "$updater" release-assets/rootline-2.0.0-darwin-universal.app.tar.gz + cp "$updater.sig" release-assets/rootline-2.0.0-darwin-universal.app.tar.gz.sig + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-darwin-universal + path: release-assets + if-no-files-found: error + + windows: + needs: preflight + environment: desktop-production + strategy: + fail-fast: false + matrix: + include: + - platform: windows-x86_64 + runner: windows-2025 + target: x86_64-pc-windows-msvc + - platform: windows-aarch64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Import Windows Authenticode certificate + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $certificatePath = Join-Path $env:RUNNER_TEMP "rootline.pfx" + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $certificate = Import-PfxCertificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\My -Password $password + if (-not $certificate.Thumbprint) { throw "Imported Windows certificate has no thumbprint." } + "WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" >> $env:GITHUB_ENV + - name: Build and Authenticode-sign Windows installer and updater + shell: pwsh + env: + TAURI_TARGET: ${{ matrix.target }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + run: | + $config = @{ bundle = @{ windows = @{ certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT; digestAlgorithm = "sha256"; timestampUrl = "http://timestamp.digicert.com" } }; plugins = @{ updater = @{ pubkey = $env:TAURI_UPDATER_PUBLIC_KEY } } } | ConvertTo-Json -Compress -Depth 5 + pnpm --filter @rootline/desktop tauri build --target $env:TAURI_TARGET --bundles nsis --config $config + if ($LASTEXITCODE -ne 0) { throw "Tauri Windows release build failed." } + - name: Verify and stage signed updater and installer + shell: pwsh + env: + PLATFORM: ${{ matrix.platform }} + TAURI_TARGET: ${{ matrix.target }} + run: | + $bundle = "apps/desktop/src-tauri/target/$env:TAURI_TARGET/release/bundle" + $installer = Get-ChildItem -Path $bundle -Recurse -File -Filter "*.exe" | Select-Object -First 1 + if (-not $installer -or -not (Test-Path "$($installer.FullName).sig")) { throw "Signed NSIS installer/updater artifacts are incomplete." } + $authenticode = Get-AuthenticodeSignature $installer.FullName + if ($authenticode.Status -ne "Valid") { throw "Installer Authenticode status is $($authenticode.Status)." } + New-Item -ItemType Directory -Path release-assets | Out-Null + Copy-Item $installer.FullName "release-assets/rootline-2.0.0-$env:PLATFORM-setup.exe" + Copy-Item "$($installer.FullName).sig" "release-assets/rootline-2.0.0-$env:PLATFORM-setup.exe.sig" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-${{ matrix.platform }} + path: release-assets + if-no-files-found: error + + publish-release: + needs: [preflight, macos-universal, windows] + runs-on: ubuntu-24.04 + environment: desktop-production + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: rootline-* + path: release-assets + merge-multiple: true + - name: Build updater latest.json from signed artifacts + env: + RELEASE_PUBLISHED_AT: ${{ github.event.head_commit.timestamp }} + run: node scripts/create-updater-manifest.mjs release-assets "https://github.com/${GITHUB_REPOSITORY}/releases/download/v2.0.0/" release-assets/latest.json + - name: Publish signed installers and updater manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + printf '%s\n' 'Rootline by baole.space 2.0.0 stable desktop release.' > release-notes.md + gh release create v2.0.0 release-assets/* --verify-tag --title "Rootline by baole.space 2.0.0" --notes-file release-notes.md diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml new file mode 100644 index 0000000..7c838ee --- /dev/null +++ b/.github/workflows/release-npm.yml @@ -0,0 +1,104 @@ +name: Release npm 2.0.0 + +"on": + push: + tags: ["v2.0.0"] + workflow_dispatch: + inputs: + confirmation: + description: Type release-v2.0.0 to publish the stable package + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-npm-2.0.0 + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-24.04 + environment: npm-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Require exact stable ref, version, and npm credential + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + CONFIRMATION: ${{ inputs.confirmation }} + run: | + set -eu + if [ "${GITHUB_REF}" != "refs/tags/v2.0.0" ]; then + echo "::error::Stable npm release is restricted to refs/tags/v2.0.0." + exit 1 + fi + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${CONFIRMATION}" != "release-v2.0.0" ]; then + echo "::error::Stable npm release blocked. Re-run with confirmation=release-v2.0.0." + exit 1 + fi + if [ -z "${NPM_TOKEN}" ]; then + echo "::error::Stable npm release blocked. Configure NPM_TOKEN as a protected npm-production environment secret with publish access to folder-structure-sync." + exit 1 + fi + node -e "const p=require('./packages/cli/package.json'); if(p.version !== '2.0.0') throw new Error('packages/cli/package.json must be version 2.0.0')" + + pack: + needs: preflight + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + checksum: ${{ steps.checksum.outputs.sha256 }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high + - run: pnpm lint && pnpm typecheck && pnpm test:unit + - run: pnpm --filter folder-structure-sync test:pack + - run: mkdir release && pnpm --filter folder-structure-sync pack --pack-destination release + - id: checksum + name: Record the exact publish tarball checksum + run: | + checksum=$(sha256sum release/folder-structure-sync-2.0.0.tgz | cut -d ' ' -f 1) + echo "${checksum} folder-structure-sync-2.0.0.tgz" > release/SHA256SUMS + echo "sha256=${checksum}" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-npm-2.0.0 + path: release + if-no-files-found: error + + publish: + needs: [preflight, pack] + runs-on: ubuntu-24.04 + environment: npm-production + permissions: + contents: none + id-token: write + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: rootline-npm-2.0.0 + path: release + - name: Verify unprivileged pack output + env: + EXPECTED_SHA256: ${{ needs.pack.outputs.checksum }} + run: | + set -eu + printf '%s %s\n' "$EXPECTED_SHA256" release/folder-structure-sync-2.0.0.tgz | sha256sum --check --strict + node -e "const p=require('child_process').execFileSync('tar',['-xOzf','release/folder-structure-sync-2.0.0.tgz','package/package.json']); const m=JSON.parse(p); if(m.name !== 'folder-structure-sync' || m.version !== '2.0.0') throw new Error('Unexpected packed npm identity')" + - name: Publish immutable npm package with provenance + run: npm publish release/folder-structure-sync-2.0.0.tgz --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index f6d3d0e..be21b15 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ coverage/ # Temporary folders tmp/ temp/ +.worktrees/ +.superpowers/sdd/ # Optional npm cache directory .npm @@ -60,6 +62,10 @@ temp/ # Build outputs dist/ build/ +apps/desktop/src-tauri/target/ +apps/desktop/src-tauri/gen/ +apps/desktop/src-tauri/icons/android/ +apps/desktop/src-tauri/icons/ios/ # Test directories (when added) test-source/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9237ca9..86c9620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.0.0] - Unreleased ### Added -- Initial release preparation -- GitHub repository setup -- Comprehensive documentation +- Rootline Desktop for macOS Universal and Windows x64/ARM64. +- A shared, deterministic additive-sync core and the `folder-sync` Node.js 20 CLI. +- Optional Authentik-hosted profile sync backed by PostgreSQL; local-only profiles remain the default. +- Fail-closed CI and protected npm, API, desktop signing, notarization, and updater workflows. + +### Changed + +- The v2 CLI entry point is `folder-sync`; the root `node index.js` program is retained only as `1.x` migration evidence. +- Stable distribution remains blocked until the documented production environments, credentials, signing identities, updater key, and reviewed `v2.0.0` tag exist and pass. + +## [1.1.0] - 2025-08-10 + +### Preserved + +- Published npm behavior recovered byte-for-byte as migration evidence. See [the 1.1.0 recovery record](docs/baseline/npm-1.1.0-recovery.md) for registry integrity and provenance details. ## [1.0.0] - 2025-08-09 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42adc6d..7a7467a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,70 +1,55 @@ -# Contributing to Folder Structure Sync +# Contributing to Rootline -Thank you for your interest in contributing! 🎉 +## Local setup -## 🚀 Quick Start +Requirements: Node.js 20+, pnpm 10.33.0, stable Rust with `rustfmt` and `clippy`, Docker with Compose, and the native Tauri 2 prerequisites for your operating system. -1. Fork the repository -2. Clone your fork: `git clone https://github.com/unique01082/folder-structure-sync.git` -3. Install dependencies: `npm install` -4. Create a feature branch: `git checkout -b feature/amazing-feature` -5. Make your changes -6. Test your changes: `node index.js test-source test-target --dry-run` -7. Commit: `git commit -m 'Add amazing feature'` -8. Push: `git push origin feature/amazing-feature` -9. Create a Pull Request +```bash +git clone https://github.com/unique01082/folder-structure-sync.git +cd folder-structure-sync +corepack enable +pnpm install --frozen-lockfile +``` -## 📋 Development Guidelines +## Development commands -### Code Style +```bash +pnpm lint +pnpm typecheck +pnpm test:unit +pnpm build +pnpm --filter folder-structure-sync test:pack +pnpm --filter @rootline/api test:e2e:postgres +cargo fmt --check --manifest-path apps/desktop/src-tauri/Cargo.toml +cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets --all-features -- -D warnings +cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml +``` -- Use clear, descriptive variable names -- Add comments for complex logic -- Follow existing patterns in the codebase -- Use meaningful commit messages +The API integration command provisions disposable PostgreSQL 16, applies the real Prisma migrations, runs the full NestJS/PostgreSQL suite (including the desktop SQLite seam), and removes the database. The npm smoke test packs and installs only the public CLI tarball; workspace packages must not hide missing registry dependencies. -### Testing +For desktop development, run `pnpm --filter @rootline/desktop tauri dev`. Local source builds are developer workflows, not user installation instructions. -- Test with various folder structures -- Always test with `--dry-run` first -- Test error conditions (permissions, missing folders, etc.) -- Test on different operating systems if possible +## Change workflow -### Pull Request Process +1. Write a focused failing test for every behavior change and verify the expected failure. +2. Implement the smallest change that passes it. +3. Run the focused test, then the relevant package suite and repository gates. +4. Update the owning document once and link to it instead of duplicating content. +5. Preserve the additive-only synchronization, local-data, privacy, and signing boundaries. -1. Update documentation if needed -2. Add examples for new features -3. Ensure backwards compatibility -4. Update CHANGELOG.md if applicable +Do not publish npm packages, push images, create releases, deploy the API, or sign artifacts from a pull request. Release workflows are intentionally isolated behind production environments and exact version confirmations. -## 🐛 Reporting Issues +## Pull request checklist -When reporting bugs, please include: +- [ ] Red/green test evidence is included. +- [ ] TypeScript and Rust gates relevant to the change pass. +- [ ] npm tarball or Tauri target smoke ran when distribution changed. +- [ ] Real PostgreSQL tests ran when API/database behavior changed. +- [ ] No tokens, absolute paths, generated credentials, or production data appear in logs/fixtures. +- [ ] Documentation links resolve and every new doc has a Related section. -- Operating system and version -- Node.js version -- Command that caused the issue -- Expected vs actual behavior -- Any error messages +## Related -## 💡 Feature Requests - -We welcome feature requests! Please: - -- Check existing issues first -- Clearly describe the use case -- Provide examples of how it would work -- Consider backwards compatibility - -## 📝 Documentation - -Help improve our documentation by: - -- Fixing typos and unclear explanations -- Adding more examples -- Improving code comments -- Updating README.md - -## 🙏 Thank You - -Every contribution helps make this project better for everyone! +- [Architecture](docs/architecture.md) - Package and trust boundaries. +- [Operations](docs/operations.md) - Local and hosted service validation. +- [Release process](docs/release.md) - Production-only gates. diff --git a/EXAMPLES.md b/EXAMPLES.md index 3ec83c3..582253e 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,222 +1,43 @@ -# Folder Structure Sync - Usage Examples +# Rootline CLI examples -## Example 1: Basic Interactive Sync +These examples cover the `2.0.0` `folder-sync` command. The preserved root `node index.js` program is `1.x` migration evidence and is not the v2 entry point. -```bash -node index.js ./my-project-source ./my-project-target -``` - -This will: - -1. Scan both directories -2. Show missing folders with colored, indented display -3. Let you select which folders to create using checkboxes -4. Auto-include parent folders for dependencies -5. Show confirmation before creating -6. Create folders with progress bar - -## Example 2: Dry Run (Preview Only) +## Preview safely ```bash -node index.js ./source ./target --dry-run +folder-sync ./source ./target --dry-run ``` -Perfect for: - -- Checking what would be created without making changes -- Planning your sync strategy -- Verifying exclusion rules work correctly +Dry-run mode scans both roots and reports missing directories without creating them. A missing target remains untouched. -## Example 3: Auto Mode (No Prompts) +## Apply every missing directory ```bash -node index.js ./source ./target --auto +folder-sync ./source ./target --auto ``` -Great for: +Without `--auto`, Rootline asks for confirmation in an interactive terminal. Rootline creates directories only; it does not copy, move, rename, or delete files. -- Automated scripts -- CI/CD pipelines -- When you trust the source structure completely - -## Example 4: Verbose Output +## Automation with JSON ```bash -node index.js ./source ./target --verbose +folder-sync ./source ./target --auto --json ``` -Shows: - -- Detailed folder creation messages -- Full paths being created -- Any warnings or errors encountered +`--json` emits one JSON document, never prompts, and is accepted only with `--dry-run` or `--auto`. Combine it with `--auto` to apply a plan in automation. Exit code `0` means success or a deliberate no-op, `1` means an operational failure, and `2` means invalid usage. -## Example 5: Combined Options +## Explicit configuration ```bash -node index.js ./source ./target --dry-run --verbose --auto -``` - -Ultimate preview mode: - -- Shows everything that would happen -- No actual changes made -- Detailed output - -## Interactive Selection Examples - -### Checkbox Interface - -``` -Missing folders found in target: -[1] ✓ src/ -[2] ✓ src/components/ -[3] ✗ src/utils/ -[4] ✓ docs/ -[5] ✗ docs/images/ -[6] ✓ tests/ - -Use arrow keys to navigate, space to toggle, enter to confirm -``` - -### Manual Number Input - -``` -Or enter folder numbers separated by commas (e.g., 1,3,5): 2,4,6 -``` - -## Configuration Examples - -### Default sync-config.json - -```json -{ - "defaultExclusions": [ - ".git", - "node_modules", - ".DS_Store", - "Thumbs.db", - ".vscode", - ".idea", - "*.tmp", - "*.log", - "dist", - "build" - ], - "customExclusions": [] -} +folder-sync ./source ./target --dry-run --config ./rootline.config.json ``` -### Custom Exclusions - ```json { - "defaultExclusions": [...], - "customExclusions": [ - "my-temp-folder", - "*.backup", - "old-*", - ".custom-cache" - ] + "defaultExclusions": [".git", "node_modules", "dist", "build"], + "customExclusions": ["private-cache"], + "targetCaseSensitive": true } ``` -## Common Use Cases - -### 1. Project Template Sync - -Keep your project templates in sync across different environments: - -```bash -node index.js ./project-template ./new-project --auto -``` - -### 2. Development Environment Setup - -Replicate folder structure for new team members: - -```bash -node index.js ./team-project-structure ./my-local-copy -``` - -### 3. Backup Folder Structure - -Create folder structure in backup location: - -```bash -node index.js ./production ./backup-structure --dry-run -# Review, then: -node index.js ./production ./backup-structure --auto -``` - -### 4. Migration Planning - -Preview folder structure changes before migration: - -```bash -node index.js ./old-structure ./new-structure --dry-run --verbose -``` - -## Error Handling Examples - -### Missing Source Directory - -``` -❌ Error: Source directory does not exist: ./non-existent-path -``` - -### Missing Target Directory - -``` -⚠️ Target directory does not exist: ./new-target -? Would you like to create the target directory? (Y/n) -``` - -### Permission Errors - -``` -❌ Error creating C:\restricted\folder: EACCES: permission denied -📊 Summary: 4 created, 1 errors -``` - -## Advanced Tips - -### 1. Test Before Production - -Always use `--dry-run` first: - -```bash -# Test -node index.js ./source ./target --dry-run - -# Execute -node index.js ./source ./target --auto -``` - -### 2. Selective Sync - -Use interactive mode to sync only specific parts: - -```bash -node index.js ./large-project ./partial-copy -# Select only the folders you need -``` - -### 3. Automation Integration - -For scripts and automation: - -```bash -# Silent, automatic execution -node index.js "$SOURCE_DIR" "$TARGET_DIR" --auto > sync.log 2>&1 -``` - -### 4. Configuration Management - -Keep different config files for different scenarios: - -```bash -# Copy appropriate config before running -cp sync-config-production.json sync-config.json -node index.js ./source ./target --auto -``` +Configuration precedence and validation are documented in [Configuration](docs/configuration.md). The safe upgrade sequence from the published `1.1.0` behavior is documented in [Migration from 1.x to 2.0.0](docs/migration-v1-to-v2.md). diff --git a/PUBLISHING.md b/PUBLISHING.md index 88c3fe1..910dbdc 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,326 +1,11 @@ -# 📦 Publishing Guide +# Publishing Rootline -This guide covers the complete process for publishing new versions of `folder-structure-sync` to npm. +Rootline `2.0.0` is published only through the fail-closed GitHub Actions workflows. Do not run `npm publish`, push the API image, upload installers, or synthesize `latest.json` manually. -## 🚀 Quick Release Commands +See [the release runbook](docs/release.md) for the exact preflight, credential, migration, signing, notarization, updater, verification, and rollback gates. -```bash -# For bug fixes (1.0.0 → 1.0.1) -npm run release:patch +## Related -# For new features (1.0.0 → 1.1.0) -npm run release:minor - -# For breaking changes (1.0.0 → 2.0.0) -npm run release:major - -# Full safety check before publishing -npm run publish:check - -# Safe publish with all checks -npm run publish:safe -``` - -## 📋 Step-by-Step Process - -### 1. Pre-Release Preparation - -```bash -# Run comprehensive checks -npm run precheck - -# Test package functionality -npm run test-package -``` - -**What the pre-check validates:** - -- ✅ All required files exist -- ✅ package.json has all required fields -- ✅ Git working directory is clean -- ✅ Logged in to npm -- ✅ Tests pass (if any) -- ⚠️ Checks for TODO/FIXME comments - -### 2. Choose Release Type - -| Type | When to Use | Version Change | Example | -| --------- | ---------------------------------- | -------------- | ------------------------ | -| **patch** | Bug fixes, minor improvements | 1.0.0 → 1.0.1 | Fix CLI argument parsing | -| **minor** | New features (backward compatible) | 1.0.0 → 1.1.0 | Add new command option | -| **major** | Breaking changes | 1.0.0 → 2.0.0 | Change CLI interface | - -### 3. Execute Release - -```bash -# Example: releasing a minor version -npm run release:minor -``` - -**What the release script does:** - -1. 🔍 Checks current branch is master -2. 📥 Pulls latest changes -3. 🧪 Runs tests (if available) -4. 📈 Updates version in package.json -5. 📝 Updates CHANGELOG.md with new version -6. 📦 Stages and commits changes -7. 🏷️ Creates git tag -8. 📤 Pushes changes and tags -9. 📋 Shows instructions for npm publish - -### 4. Review and Publish - -After running the release script: - -1. **Review the changes:** - - ```bash - git log --oneline -5 - git show HEAD - ``` - -2. **Publish to npm:** - - ```bash - npm publish - ``` - -3. **Create GitHub Release:** - - Go to: https://github.com/unique01082/folder-structure-sync/releases/new - - Select the new tag - - Add release notes from CHANGELOG.md - - Publish release - -## 🔧 Manual Process (if needed) - -If you prefer manual control: - -### 1. Version Update - -```bash -# Update version manually -npm version patch # or minor/major -``` - -### 2. Update CHANGELOG.md - -Add new section with current date: - -```markdown -## [1.0.1] - 2025-08-09 - -### Fixed - -- Bug fixes and improvements - -### Changed - -- Minor updates and optimizations -``` - -### 3. Commit and Tag - -```bash -git add . -git commit -m "chore: release v1.0.1" -git tag v1.0.1 -git push origin master --tags -``` - -### 4. Publish - -```bash -npm publish -``` - -## 🧪 Testing Before Release - -### Local Testing - -```bash -# Test package creation and functionality -npm run test-package -``` - -### Test Installation - -```bash -# Create package and test locally -npm pack -npm install -g folder-structure-sync-1.0.1.tgz - -# Test commands -folder-sync --help -folder-sync ./test-source ./test-target --dry-run - -# Cleanup -npm uninstall -g folder-structure-sync -rm folder-structure-sync-1.0.1.tgz -``` - -## 📊 Post-Release Tasks - -### 1. Verify Publication - -```bash -# Check package on npm -npm view folder-structure-sync - -# Test installation -npx folder-structure-sync@latest --help -``` - -### 2. Update Documentation - -- [ ] Update README.md if needed -- [ ] Update examples in EXAMPLES.md -- [ ] Check all links work correctly - -### 3. Monitor and Promote - -- [ ] Check npm download stats -- [ ] Share on social media -- [ ] Update relevant communities - -## 🚨 Troubleshooting - -### Common Issues - -**Git not clean:** - -```bash -git status -git stash # if you want to save changes -# or -git add . && git commit -m "WIP: save changes" -``` - -**Not logged in to npm:** - -```bash -npm login -npm whoami # verify -``` - -**Version already exists:** - -```bash -# If you need to republish same version (not recommended) -npm publish --force - -# Better: increment version -npm version patch -npm publish -``` - -**Permission denied:** - -```bash -# Check if you're a maintainer -npm owner ls folder-structure-sync - -# Or contact current maintainer -``` - -### Recovery from Failed Release - -If release script fails midway: - -```bash -# Reset version if needed -git reset --hard HEAD~1 -git tag -d v1.0.1 # if tag was created - -# Or continue from where it failed -git push origin master -git push origin --tags -npm publish -``` - -## 📈 Version Strategy - -### Semantic Versioning (SemVer) - -- **MAJOR**: Breaking changes (2.0.0) - - - Change CLI interface - - Remove features - - Change file formats - -- **MINOR**: New features (1.1.0) - - - Add new options - - Add new commands - - Enhance existing features - -- **PATCH**: Bug fixes (1.0.1) - - Fix bugs - - Update dependencies - - Improve performance - -### Pre-release Versions - -For testing major changes: - -```bash -npm version 2.0.0-beta.1 -npm publish --tag beta - -# Users can test with: -npm install folder-structure-sync@beta -``` - -## 🎯 Checklist Template - -Before each release: - -- [ ] All features implemented and tested -- [ ] Documentation updated -- [ ] CHANGELOG.md entries added -- [ ] No TODO/FIXME in critical code -- [ ] Git working directory clean -- [ ] Logged in to npm -- [ ] Pre-publish checks pass -- [ ] Package tests pass - -After release: - -- [ ] npm package verified -- [ ] GitHub release created -- [ ] Documentation links updated -- [ ] Social media announcement -- [ ] Monitor for issues - -## 📝 Release Notes Template - -```markdown -## 🎉 folder-structure-sync v1.1.0 - -### ✨ New Features - -- Added support for custom configuration profiles -- Improved interactive selection with keyboard shortcuts - -### 🐛 Bug Fixes - -- Fixed issue with Windows path handling -- Resolved memory leak with large directory structures - -### 📖 Documentation - -- Updated examples with new features -- Added troubleshooting guide - -### 📦 Installation - -\`\`\`bash -npm install -g folder-structure-sync@latest -\`\`\` - -### 🔗 Links - -- [Full Changelog](https://github.com/unique01082/folder-structure-sync/blob/master/CHANGELOG.md) -- [Documentation](https://github.com/unique01082/folder-structure-sync#readme) -- [Issues](https://github.com/unique01082/folder-structure-sync/issues) -``` +- [Release runbook](docs/release.md) - Single source of truth for stable releases. +- [Operations](docs/operations.md) - API deployment and health checks. +- [Security policy](SECURITY.md) - Supply-chain requirements. diff --git a/QUICK-REFERENCE.md b/QUICK-REFERENCE.md index 25cb1ed..a08d10e 100644 --- a/QUICK-REFERENCE.md +++ b/QUICK-REFERENCE.md @@ -1,80 +1,19 @@ -# 🚀 Quick Reference - Publishing New Versions +# Rootline release quick reference -## ⚡ One-Command Publishing +Stable Rootline releases are workflow-only and fail closed. Do not run `npm publish`, create a GitHub release, deploy the API, or sign installers from a local checkout. -```bash -# Bug fixes (1.0.0 → 1.0.1) -npm run release:patch - -# New features (1.0.0 → 1.1.0) -npm run release:minor - -# Breaking changes (1.0.0 → 2.0.0) -npm run release:major -``` - -Then: `npm publish` - -## 🔍 Pre-Flight Checks - -```bash -# Run all checks -npm run precheck - -# Test package functionality -npm run test-package - -# Both checks + publish -npm run publish:safe -``` - -## 📊 Post-Release Tools - -```bash -# Package stats -npm run workflow:stats - -# Social media posts -npm run workflow:social - -# Promotion checklist -npm run workflow:promotion - -# Command reference -npm run workflow:commands -``` - -## 🎯 Release Workflow - -1. **Prepare** → `npm run precheck` -2. **Release** → `npm run release:minor` -3. **Publish** → `npm publish` -4. **Promote** → `npm run workflow:social` +Use [the release runbook](docs/release.md) for the exact npm, API, and desktop gates. The runbook identifies the protected GitHub environments, required production configuration, validation sequence, and external credentials that currently block a real `2.0.0` release. -## 🆘 Emergency Commands +Local validation is safe and does not publish: ```bash -# Check what will be published -npm pack --dry-run - -# Verify npm login -npm whoami - -# Check package on npm -npm view folder-structure-sync - -# Test installation -npx folder-structure-sync@latest --help +pnpm install --frozen-lockfile +pnpm lint +pnpm typecheck +pnpm test +pnpm build +pnpm test:release +pnpm validate:workflows ``` -## 📁 Files Created - -- `scripts/release.js` - Automated release process -- `scripts/pre-publish-check.js` - Pre-flight validation -- `scripts/test-package.js` - Package functionality testing -- `scripts/workflow.js` - Workflow automation helpers -- `PUBLISHING.md` - Detailed publishing guide - -## 🎉 Ready to Ship! - -Your package is now equipped with professional release automation. Just run the commands and follow the prompts! +Rootline `2.0.0` must not be described as shipped until every protected workflow completes from the reviewed `v2.0.0` tag. diff --git a/README.md b/README.md index f0a6b4e..bf2eeb1 100644 --- a/README.md +++ b/README.md @@ -1,296 +1,60 @@ -# 📁 Folder Structure Sync +# Rootline by baole.space -[![npm version](https://img.shields.io/npm/v/folder-structure-sync.svg?style=flat-square)](https://www.npmjs.com/package/folder-structure-sync) -[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg?style=flat-square)](https://opensource.org/licenses/ISC) -[![Node.js Version](https://img.shields.io/badge/node-%3E%3D12.0.0-brightgreen.svg?style=flat-square)](https://nodejs.org/) +Rootline safely reproduces a directory structure from one location into another without copying files or deleting existing content. -> 🚀 **Interactive CLI tool for syncing folder structures with smart selection, dependency handling, and beautiful output** +> **Release status:** `2.0.0` is a release candidate. Stable npm and desktop downloads are available only after the fail-closed release workflows complete with real signing and deployment credentials. Check [GitHub Releases](https://github.com/unique01082/folder-structure-sync/releases) and [npm](https://www.npmjs.com/package/folder-structure-sync) before installing; this repository does not claim unpublished artifacts are live. -Perfect for project templates, development environments, team onboarding, and automated deployments. Sync only what you need with intelligent dependency resolution and comprehensive exclusion patterns. +## Quick start -image +1. **Choose:** use the desktop application on macOS Universal or Windows x64/ARM64, or use the `folder-sync` CLI with Node.js 20 or newer. +2. **Install:** after `2.0.0` appears on the official release pages, download a signed installer or run `npm install --global folder-structure-sync@2.0.0`. +3. **Authenticate (optional):** sign in only if you want saved profiles synchronized. Rootline works fully offline and never requires an account for local synchronization. +4. **Try safely:** preview before applying: `folder-sync ./source ./target --dry-run`. +## What Rootline does -## 📚 Table of Contents +| Capability | Desktop | CLI | +|---|---:|---:| +| Scan source and target directories | Yes | Yes | +| Preview and select missing directories | Yes | Yes | +| Create missing directories additively | Yes | Yes | +| Copy files or delete content | Never | Never | +| Save local profiles and run history | Yes | No | +| Optional hosted profile sync | Yes | No | -- [✨ Features](#-features) -- [🚀 Quick Start](#-quick-start) -- [📦 Installation](#-installation) -- [📖 Usage](#-usage) - - [🎮 Interactive Mode](#-interactive-mode) - - [🔧 Command Options](#-command-options) - - [💡 Common Use Cases](#-common-use-cases) -- [⚙️ Configuration](#️-configuration) -- [🎯 Smart Features](#-smart-features) - - [🎮 Interactive Selection](#-interactive-selection) - - [🧠 Dependency Resolution](#-dependency-resolution) - - [🎨 Beautiful Output](#-beautiful-output) -- [📸 Screenshots & Examples](#-screenshots--examples) - - [✅ Success Output](#-success-output) - - [📋 Dry Run Output](#-dry-run-output) - - [⚠️ Error Handling](#️-error-handling) -- [🛠️ Development](#️-development) -- [🤝 Contributing](#-contributing) -- [📋 Roadmap](#-roadmap) -- [❓ FAQ](#-faq) -- [🔧 Troubleshooting](#-troubleshooting) -- [📄 License](#-license) -- [🙏 Acknowledgments](#-acknowledgments) +Rootline rejects source/target overlap, skips symbolic links and Windows junctions, revalidates a plan immediately before applying it, and keeps filesystem data and run history local. Hosted sync contains complete saved profile documents—including absolute source and target paths—but never directory trees, files, file contents, or run history. Rootline has no usage telemetry. -## ✨ Features +## CLI -- 🎯 **Interactive Selection**: Choose folders with checkbox interface or comma-separated numbers -- 🧠 **Smart Dependencies**: Auto-includes parent folders when children are selected -- 🚫 **Smart Exclusions**: Configurable patterns with sensible defaults (`.git`, `node_modules`, etc.) -- 🎨 **Beautiful Output**: Colorful, hierarchical display with progress bars -- 📋 **Dry Run Mode**: Preview changes safely before execution -- ⚡ **Auto Mode**: Perfect for scripts and CI/CD pipelines -- 📊 **Detailed Reporting**: Comprehensive operation summaries -- 🔧 **Cross-Platform**: Works on Windows, macOS, and Linux - -## 🚀 Quick Start - -```bash -# Install globally from npm -npm install -g folder-structure-sync - -# Or install locally in your project -npm install folder-structure-sync - -# Run interactively -folder-sync ./source-folder ./target-folder - -# Preview changes (recommended first run) -folder-sync ./source-folder ./target-folder --dry-run - -# Auto-sync everything -folder-sync ./source-folder ./target-folder --auto -``` - -## 📦 Installation - -```bash -# Global installation (recommended) -npm install -g folder-structure-sync - -# Local installation -npm install folder-structure-sync - -# Or run directly with npx (no installation needed) -npx folder-structure-sync ./source ./target --dry-run -``` - -## 📖 Usage - -### 🎮 Interactive Mode - -The default mode provides a user-friendly selection interface: - -```bash -folder-sync ./source-project ./target-project -``` - -**Example interaction:** - -``` -📂 Found 5 missing folders in target: -[1] ✓ src/ -[2] ✓ src/components/ -[3] ✗ src/utils/ -[4] ✓ docs/ -[5] ✓ tests/ - -🎯 Select folders to create: - Use arrow keys to navigate, space to toggle, enter to confirm -``` - -### 🔧 Command Options - -- `-d, --dry-run`: Preview changes without executing -- `-v, --verbose`: Show detailed output -- `-a, --auto`: Auto-create all missing folders without prompting -- `-h, --help`: Show help information - -### 💡 Common Use Cases - -```bash -# 🔍 Preview changes (recommended first!) -folder-sync ./my-template ./new-project --dry-run - -# 🏗️ Project template setup -folder-sync ./project-template ./new-project --auto - -# 👥 Team environment replication -folder-sync ./team-structure ./my-local-copy - -# 📦 Selective sync with verbose output -folder-sync ./large-project ./partial-copy --verbose - -# 🤖 Automation/CI-CD pipeline -folder-sync "$SOURCE" "$TARGET" --auto -``` - -## ⚙️ Configuration - -The tool uses a `sync-config.json` file for exclusion patterns: - -```json -{ - "defaultExclusions": [ - ".git", - ".svn", - ".hg", // Version control - "node_modules", - ".npm", - ".yarn", // Package managers - ".DS_Store", - "Thumbs.db", // OS files - ".vscode", - ".idea", // IDE files - "*.tmp", - "*.log", - "*.cache", // Temporary files - "dist", - "build", - ".next" // Build outputs - ], - "customExclusions": [ - "my-custom-folder", // Add your patterns here - "*.backup" - ] -} -``` - -**💡 Pro tip**: Customize `customExclusions` for project-specific needs! - -## 🎯 Smart Features - -### 🎮 Interactive Selection - -**Two ways to select folders:** - -1. **Checkbox Interface**: Navigate with `↑↓`, toggle with `Space`, confirm with `Enter` -2. **Number Input**: Type comma-separated numbers like `1,3,5` or ranges `1-5` - -### 🧠 Dependency Resolution - -When you select `src/components/buttons/`, the tool automatically: - -- ✅ Includes parent folders: `src/` → `src/components/` → `src/components/buttons/` -- 📋 Shows you the complete dependency tree -- ⚡ Creates folders in the correct order - -### 🎨 Beautiful Output - -``` -🔍 Validating paths... -📁 Scanning directories... - -📂 Found 5 missing folders: - [1] src/ # Root level - cyan - [2] src/components/ # Level 1 - yellow - [3] src/components/ui/ # Level 2 - green - [4] docs/ # Root level - cyan - [5] tests/ # Root level - cyan - -🚀 Creating folders... -Progress |████████████████████| 100% | 5/5 folders - -🎉 Success: 5 created, 0 errors -``` - -## 📸 Screenshots & Examples - -### ✅ Success Output - -``` -🎉 Successfully processed 5 folders! -📊 Summary: 5 created, 0 errors -``` - -### 📋 Dry Run Output +```text +folder-sync [options] +--dry-run Preview without creating directories +--verbose Print scan details +--auto Select all missing directories without prompting +--config PATH Read an explicit JSON configuration +--json Emit one JSON document and never prompt ``` -📋 Dry run - folders that would be created: - 1. /target/src - 2. /target/src/components - 3. /target/docs - 4. /target/tests - -📋 This was a dry run - no actual changes were made. -``` - -### ⚠️ Error Handling - -``` -❌ Error creating /restricted/folder: EACCES: permission denied -📊 Summary: 4 created, 1 error -``` - -## 🛠️ Development - -```bash -# Clone and setup -git clone https://github.com/unique01082/folder-structure-sync.git -cd folder-structure-sync -npm install - -# Run tests (when available) -npm test - -# Test with sample data -node index.js ./test-source ./test-target --dry-run -``` - -## 🤝 Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. - -1. Fork the project -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -## 📋 Roadmap - -- [x] 📦 NPM package publication -- [ ] 🧪 Comprehensive test suite -- [ ] 📊 File sync capabilities (not just folders) -- [ ] 🌐 Configuration presets for popular frameworks -- [ ] 🔍 Advanced filtering with regex patterns -- [ ] 📱 Interactive web interface -- [ ] ⚡ Performance optimizations for large directories - -## ❓ FAQ - -**Q: Does this tool copy files?** -A: No, it only creates folder structures. Files are not copied or modified. - -**Q: Is it safe to use in production?** -A: Yes, especially with `--dry-run` first. The tool only creates folders and includes comprehensive error handling. - -**Q: Can I exclude specific patterns?** -A: Absolutely! Use `sync-config.json` to customize exclusion patterns. - -**Q: Does it work with network drives?** -A: Yes, as long as you have appropriate permissions. - -## 🔧 Troubleshooting -**Permission Errors**: Run with elevated privileges or check folder permissions -**Large Directories**: Use `--verbose` to monitor progress -**Configuration Issues**: Check `sync-config.json` syntax with a JSON validator +`--json` is intended for automation and must be paired with `--dry-run` or `--auto`; it never prompts or emits progress/color output. Exit code `0` means success or a deliberate no-op, `1` means a filesystem/configuration/apply failure, and `2` means invalid command usage. -## 📄 License +## Documentation -This project is licensed under the ISC License - see the [LICENSE](LICENSE) file for details. +- [Documentation hub](docs/README.md) +- [Configuration reference](docs/configuration.md) +- [Architecture and data boundaries](docs/architecture.md) +- [Migrate from npm 1.1.0](docs/migration-v1-to-v2.md) +- [Privacy](docs/privacy.md) +- [Operations](docs/operations.md) +- [Release process](docs/release.md) +- [Security policy](SECURITY.md) +- [Contributor workflow](CONTRIBUTING.md) -## 🙏 Acknowledgments +## License -- Built with ❤️ using [Commander.js](https://github.com/tj/commander.js/), [Chalk](https://github.com/chalk/chalk), [Inquirer.js](https://github.com/SBoudrias/Inquirer.js/), and [CLI Progress](https://github.com/npkgz/cli-progress) -- Inspired by the need for better development environment synchronization +Rootline remains available under the [ISC License](LICENSE). ---- +## Related -**⭐ Star this repo if it helped you!** | **🐛 [Report bugs](https://github.com/unique01082/folder-structure-sync/issues)** | **💡 [Request features](https://github.com/unique01082/folder-structure-sync/issues)** +- [Rootline documentation](docs/README.md) - Complete user, operator, and contributor navigation. +- [npm 1.1.0 recovery evidence](docs/baseline/npm-1.1.0-recovery.md) - Provenance of the preserved legacy behavior. diff --git a/SECURITY.md b/SECURITY.md index 2dfe8da..149b7d5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,44 +1,37 @@ -# Security Policy +# Rootline security policy -## Supported Versions +## Supported versions -We support the latest version of folder-structure-sync. Please ensure you're using the most recent version before reporting security issues. +| Version | Status | +|---|---| +| `2.0.x` | Supported after the stable `2.0.0` release | +| `1.1.x` | Security fixes only during the v2 migration window | +| Earlier | Unsupported | -| Version | Supported | -| ------- | ------------------ | -| 1.x.x | :white_check_mark: | +## Report a vulnerability -## Reporting a Vulnerability +Use a private GitHub security advisory for `unique01082/folder-structure-sync`. Do not include tokens, absolute filesystem paths, database credentials, signing keys, or personal data in a public issue. Include affected version/platform, impact, reproduction steps, and any known mitigations. -If you discover a security vulnerability, please report it by emailing [your-email@example.com] or creating a private security advisory on GitHub. +Release signing or hosted-service credentials are never accepted through issues or pull requests. Rotate any credential accidentally disclosed in logs or source before continuing a release. -**Please do NOT report security vulnerabilities through public GitHub issues.** +## Security boundaries -When reporting a vulnerability, please include: +- Local synchronization is one-way and additive: Rootline creates missing directories only. +- Source and target cannot be equal, ancestors, descendants, symbolic links, or Windows junction aliases. +- The plan is revalidated before mutation; cancellation stops between directory operations. +- Offline profiles, directory trees, file names/content, device state, and run history remain local. +- Optional hosted sync sends complete saved profiles, including absolute source and target paths, only after explicit sign-in and consent. +- OIDC uses Authorization Code with PKCE. Tokens and protocol state are held behind the native Stronghold/OS credential boundary, not localStorage or React state. +- API tenant identity comes only from the verified RS256 token subject and permission claim. +- Production logs must not contain bearer tokens, request bodies, profile values, or absolute paths. +- Rootline contains no usage telemetry. -- Description of the vulnerability -- Steps to reproduce the issue -- Potential impact -- Any suggested fixes +## Supply-chain and stable release controls -We will respond to security reports within 48 hours and provide regular updates on our progress. +Stable npm publication is restricted to `v2.0.0`, requires npm provenance, and installs the packed CLI in isolation before publishing. Stable API deployment requires an immutable image, checked-in PostgreSQL migrations, HTTPS deployment/health endpoints, and complete production secrets. Desktop release requires Apple signing and notarization, Windows Authenticode signing, and a non-empty Tauri updater signature for every platform. Missing inputs stop the release with an actionable error; signatures are never disabled as a fallback. -## Security Considerations +## Related -This tool: - -- Only creates directories (never modifies or deletes existing content) -- Respects filesystem permissions -- Does not execute any external commands -- Does not transmit data over the network -- Reads configuration only from local JSON files - -## Best Practices - -When using this tool: - -- Always use `--dry-run` first in production environments -- Review the list of folders to be created before confirming -- Ensure you have appropriate permissions for the target directory -- Use version control for your configuration files -- Regularly update to the latest version +- [Privacy](docs/privacy.md) - Data collection and hosted profile scope. +- [Architecture](docs/architecture.md) - Trust boundaries and ownership. +- [Release process](docs/release.md) - Fail-closed release gates. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..5d47805 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,45 @@ +FROM node:20-bookworm-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0 AS base + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +FROM base AS build + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +WORKDIR /app + +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/api/package.json apps/api/package.json +COPY packages/contracts/package.json packages/contracts/package.json +RUN pnpm install --frozen-lockfile --filter @rootline/api... + +COPY apps/api apps/api +COPY packages/contracts packages/contracts +RUN pnpm --filter @rootline/contracts build \ + && pnpm --filter @rootline/api prisma:generate \ + && pnpm --filter @rootline/api build + +FROM base AS runtime + +ARG ROOTLINE_BUILD_ID=development +ENV NODE_ENV=production +ENV PORT=3000 +ENV ROOTLINE_BUILD_ID=$ROOTLINE_BUILD_ID +WORKDIR /app + +RUN groupadd --system rootline && useradd --system --gid rootline rootline +COPY --from=build --chown=rootline:rootline /app/node_modules /app/node_modules +COPY --from=build --chown=rootline:rootline /app/apps/api/node_modules /app/apps/api/node_modules +COPY --from=build --chown=rootline:rootline /app/apps/api/dist /app/apps/api/dist +COPY --from=build --chown=rootline:rootline /app/apps/api/package.json /app/apps/api/package.json +COPY --from=build --chown=rootline:rootline /app/apps/api/prisma /app/apps/api/prisma +COPY --from=build --chown=rootline:rootline /app/packages/contracts/dist /app/packages/contracts/dist +COPY --from=build --chown=rootline:rootline /app/packages/contracts/package.json /app/packages/contracts/package.json + +USER rootline +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] +CMD ["node", "apps/api/dist/main.js"] diff --git a/apps/api/compose.test.yml b/apps/api/compose.test.yml new file mode 100644 index 0000000..9065f26 --- /dev/null +++ b/apps/api/compose.test.yml @@ -0,0 +1,16 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: rootline + POSTGRES_PASSWORD: rootline + POSTGRES_DB: rootline_test + ports: + - "127.0.0.1:55433:5432" + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rootline -d rootline_test"] + interval: 1s + timeout: 3s + retries: 30 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..91d8674 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,46 @@ +{ + "name": "@rootline/api", + "version": "2.0.0", + "private": true, + "description": "Rootline hosted profile synchronization API", + "license": "ISC", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js", + "test": "sh scripts/test-e2e-postgres.sh", + "test:e2e": "vitest run test/sync.e2e.spec.ts", + "test:e2e:postgres": "sh scripts/test-e2e-postgres.sh", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prisma:generate": "prisma generate", + "prisma:migrate:deploy": "prisma migrate deploy" + }, + "dependencies": { + "@rootline/contracts": "workspace:*", + "@nestjs/common": "^11.1.6", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.1.6", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.1.6", + "@nestjs/swagger": "^11.2.0", + "@prisma/client": "^6.16.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "express": "^5.1.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@nestjs/testing": "^11.1.6", + "@types/passport-jwt": "^4.0.1", + "@types/express": "^5.0.3", + "@types/supertest": "^6.0.3", + "jose": "^6.1.0", + "prisma": "^6.16.2", + "supertest": "^7.1.4", + "tsx": "^4.20.5", + "vitest": "^3.2.4" + } +} diff --git a/apps/api/prisma/migrations/20260815050000_hosted_profile_sync/migration.sql b/apps/api/prisma/migrations/20260815050000_hosted_profile_sync/migration.sql new file mode 100644 index 0000000..10e893f --- /dev/null +++ b/apps/api/prisma/migrations/20260815050000_hosted_profile_sync/migration.sql @@ -0,0 +1,42 @@ +CREATE TABLE "user_sync_state" ( + "subject" TEXT NOT NULL, + "epoch" UUID NOT NULL, + "revision" BIGINT NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "user_sync_state_pkey" PRIMARY KEY ("subject") +); + +CREATE TABLE "profile_record" ( + "subject" TEXT NOT NULL, + "profile_id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "profile" JSONB, + "deleted_at" TIMESTAMP(3), + "revision" BIGINT NOT NULL, + "committed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "profile_record_pkey" PRIMARY KEY ("subject", "profile_id") +); + +CREATE TABLE "sync_change" ( + "subject" TEXT NOT NULL, + "revision" BIGINT NOT NULL, + "record" JSONB NOT NULL, + "committed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "sync_change_pkey" PRIMARY KEY ("subject", "revision") +); + +CREATE TABLE "mutation_receipt" ( + "subject" TEXT NOT NULL, + "mutation_id" UUID NOT NULL, + "revision" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "mutation_receipt_pkey" PRIMARY KEY ("subject", "mutation_id") +); + +CREATE INDEX "profile_record_subject_revision_idx" ON "profile_record"("subject", "revision"); +CREATE INDEX "mutation_receipt_expires_at_idx" ON "mutation_receipt"("expires_at"); +ALTER TABLE "profile_record" ADD CONSTRAINT "profile_record_subject_fkey" FOREIGN KEY ("subject") REFERENCES "user_sync_state"("subject") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "sync_change" ADD CONSTRAINT "sync_change_subject_fkey" FOREIGN KEY ("subject") REFERENCES "user_sync_state"("subject") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "mutation_receipt" ADD CONSTRAINT "mutation_receipt_subject_fkey" FOREIGN KEY ("subject") REFERENCES "user_sync_state"("subject") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260815051000_bind_receipt_payload/migration.sql b/apps/api/prisma/migrations/20260815051000_bind_receipt_payload/migration.sql new file mode 100644 index 0000000..7eb1ca6 --- /dev/null +++ b/apps/api/prisma/migrations/20260815051000_bind_receipt_payload/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "mutation_receipt" ADD COLUMN "mutation_hash" TEXT NOT NULL DEFAULT ''; diff --git a/apps/api/prisma/migrations/20260815100000_durable_mutation_dedup/migration.sql b/apps/api/prisma/migrations/20260815100000_durable_mutation_dedup/migration.sql new file mode 100644 index 0000000..b582b9a --- /dev/null +++ b/apps/api/prisma/migrations/20260815100000_durable_mutation_dedup/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE "mutation_dedup" ( + "subject" TEXT NOT NULL, + "mutation_id" UUID NOT NULL, + "mutation_hash" TEXT NOT NULL, + "revision" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "mutation_dedup_pkey" PRIMARY KEY ("subject", "mutation_id") +); + +INSERT INTO "mutation_dedup" ("subject", "mutation_id", "mutation_hash", "revision", "created_at") +SELECT "subject", "mutation_id", "mutation_hash", "revision", "created_at" +FROM "mutation_receipt"; + +ALTER TABLE "mutation_dedup" +ADD CONSTRAINT "mutation_dedup_subject_fkey" +FOREIGN KEY ("subject") REFERENCES "user_sync_state"("subject") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260815120000_last_device_id/migration.sql b/apps/api/prisma/migrations/20260815120000_last_device_id/migration.sql new file mode 100644 index 0000000..dbfbf0d --- /dev/null +++ b/apps/api/prisma/migrations/20260815120000_last_device_id/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "profile_record" +ADD COLUMN "last_device_id" TEXT NOT NULL DEFAULT 'legacy-unknown'; + +ALTER TABLE "profile_record" +ALTER COLUMN "last_device_id" DROP DEFAULT; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..336d5ab --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,80 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model UserSyncState { + subject String @id + epoch String @db.Uuid + revision BigInt @default(0) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + profiles ProfileRecord[] + changes SyncChange[] + receipts MutationReceipt[] + dedupEntries MutationDedup[] + + @@map("user_sync_state") +} + +model ProfileRecord { + subject String + profileId String @map("profile_id") + kind String + profile Json? + deletedAt DateTime? @map("deleted_at") + revision BigInt + lastDeviceId String @map("last_device_id") + committedAt DateTime @default(now()) @map("committed_at") + + owner UserSyncState @relation(fields: [subject], references: [subject], onDelete: Cascade) + + @@id([subject, profileId]) + @@index([subject, revision]) + @@map("profile_record") +} + +model SyncChange { + subject String + revision BigInt + record Json + committedAt DateTime @default(now()) @map("committed_at") + + owner UserSyncState @relation(fields: [subject], references: [subject], onDelete: Cascade) + + @@id([subject, revision]) + @@map("sync_change") +} + +model MutationReceipt { + subject String + mutationId String @db.Uuid @map("mutation_id") + mutationHash String @map("mutation_hash") + revision BigInt + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + + owner UserSyncState @relation(fields: [subject], references: [subject], onDelete: Cascade) + + @@id([subject, mutationId]) + @@index([expiresAt]) + @@map("mutation_receipt") +} + +model MutationDedup { + subject String + mutationId String @db.Uuid @map("mutation_id") + mutationHash String @map("mutation_hash") + revision BigInt + createdAt DateTime @default(now()) @map("created_at") + + owner UserSyncState @relation(fields: [subject], references: [subject], onDelete: Cascade) + + @@id([subject, mutationId]) + @@map("mutation_dedup") +} diff --git a/apps/api/scripts/test-docker-image.sh b/apps/api/scripts/test-docker-image.sh new file mode 100644 index 0000000..f3fd958 --- /dev/null +++ b/apps/api/scripts/test-docker-image.sh @@ -0,0 +1,94 @@ +#!/bin/sh +set -eu + +smoke_suffix="${ROOTLINE_API_SMOKE_SUFFIX:-$$}" +smoke_image="rootline-api-smoke:${smoke_suffix}" +smoke_network="rootline-api-smoke-${smoke_suffix}" +postgres_container="rootline-api-smoke-postgres-${smoke_suffix}" +api_container="rootline-api-smoke-api-${smoke_suffix}" +build_id="rootline-api-smoke-${smoke_suffix}" +smoke_directory=$(mktemp -d "${TMPDIR:-/tmp}/rootline-api-image-smoke.XXXXXX") +jwks_path="$smoke_directory/jwks.json" + +cleanup() { + docker rm --force "$api_container" "$postgres_container" >/dev/null 2>&1 || true + docker network rm "$smoke_network" >/dev/null 2>&1 || true + docker image rm "$smoke_image" >/dev/null 2>&1 || true + rm -f "$jwks_path" + rmdir "$smoke_directory" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +node - "$jwks_path" <<'NODE' +const { generateKeyPairSync } = require("node:crypto"); +const { writeFileSync } = require("node:fs"); +const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const key = publicKey.export({ format: "jwk" }); +Object.assign(key, { kid: "rootline-image-smoke", alg: "RS256", use: "sig" }); +writeFileSync(process.argv[2], JSON.stringify({ keys: [key] })); +NODE + +docker build --file apps/api/Dockerfile \ + --build-arg "ROOTLINE_BUILD_ID=$build_id" \ + --tag "$smoke_image" . + +docker network create "$smoke_network" >/dev/null +docker run --detach \ + --name "$postgres_container" \ + --network "$smoke_network" \ + --network-alias postgres \ + --publish 127.0.0.1::5432 \ + --tmpfs /var/lib/postgresql/data \ + --env POSTGRES_USER=rootline \ + --env POSTGRES_PASSWORD=rootline \ + --env POSTGRES_DB=rootline_smoke \ + postgres:16-alpine >/dev/null + +postgres_ready=false +for attempt in $(seq 1 30); do + if docker exec "$postgres_container" pg_isready --username rootline --dbname rootline_smoke >/dev/null 2>&1; then + postgres_ready=true + break + fi + if [ "$attempt" -eq 30 ]; then + docker logs "$postgres_container" + else + sleep 1 + fi +done +[ "$postgres_ready" = true ] + +postgres_mapping=$(docker port "$postgres_container" 5432/tcp) +postgres_port=${postgres_mapping##*:} +host_database_url="postgresql://rootline:rootline@127.0.0.1:${postgres_port}/rootline_smoke?schema=public" +DATABASE_URL="$host_database_url" pnpm --filter @rootline/api exec prisma migrate deploy + +docker run --detach \ + --name "$api_container" \ + --network "$smoke_network" \ + --read-only \ + --tmpfs /tmp \ + --mount "type=bind,source=$jwks_path,target=/run/rootline/jwks.json,readonly" \ + --env DATABASE_URL="postgresql://rootline:rootline@postgres:5432/rootline_smoke?schema=public" \ + --env JWT_ISSUER=https://auth.rootline.invalid/application/o/rootline/ \ + --env JWT_AUDIENCE=rootline-desktop-smoke \ + --env JWT_JWKS_PATH=/run/rootline/jwks.json \ + "$smoke_image" >/dev/null + +api_healthy=false +for attempt in $(seq 1 30); do + health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$api_container") + if [ "$health" = healthy ]; then + api_healthy=true + break + fi + if [ "$health" = unhealthy ] || [ "$attempt" -eq 30 ]; then + docker logs "$api_container" + else + sleep 1 + fi +done +[ "$api_healthy" = true ] + +docker exec "$api_container" node -e \ + "fetch('http://127.0.0.1:3000/healthz').then(async response => { const body = await response.json(); if (!response.ok || body.status !== 'ok' || body.buildId !== '$build_id') process.exit(1); }).catch(() => process.exit(1))" diff --git a/apps/api/scripts/test-e2e-postgres.sh b/apps/api/scripts/test-e2e-postgres.sh new file mode 100644 index 0000000..4b5c709 --- /dev/null +++ b/apps/api/scripts/test-e2e-postgres.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +compose_file="$(dirname "$0")/../compose.test.yml" +project="rootline-api-e2e" +database_url="postgresql://rootline:rootline@127.0.0.1:55433/rootline_test?schema=public" + +cleanup() { + docker compose -p "$project" -f "$compose_file" down --volumes >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +docker compose -p "$project" -f "$compose_file" up --detach --wait +DATABASE_URL="$database_url" pnpm exec prisma migrate deploy +DATABASE_URL="$database_url" pnpm exec vitest run test/sync.e2e.spec.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..a7ffa95 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,30 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { APP_GUARD, Reflector } from "@nestjs/core"; +import { PassportModule } from "@nestjs/passport"; + +import { JwtAuthGuard, JwtStrategy, PermissionsGuard } from "./auth.js"; +import type { RootlineApiConfig } from "./config.js"; +import { HealthController } from "./health.controller.js"; +import { PrismaService } from "./prisma.service.js"; +import { UserRateLimitGuard } from "./rate-limit.guard.js"; +import { SyncController } from "./sync.controller.js"; +import { SyncService } from "./sync.service.js"; + +export function createAppModule(config: RootlineApiConfig) { + @Module({ + imports: [ConfigModule.forRoot({ ignoreEnvFile: true }), PassportModule.register({ defaultStrategy: "jwt" })], + controllers: [HealthController, SyncController], + providers: [ + { provide: "ROOTLINE_API_CONFIG", useValue: config }, + { provide: PrismaService, useFactory: () => new PrismaService(config.databaseUrl) }, + { provide: JwtStrategy, useFactory: () => new JwtStrategy(config) }, + SyncService, + { provide: APP_GUARD, useFactory: (reflector: Reflector) => new JwtAuthGuard(reflector), inject: [Reflector] }, + { provide: APP_GUARD, useFactory: (reflector: Reflector) => new PermissionsGuard(reflector), inject: [Reflector] }, + { provide: APP_GUARD, useFactory: (reflector: Reflector) => new UserRateLimitGuard(reflector, config), inject: [Reflector] }, + ], + }) + class RootlineAppModule {} + return RootlineAppModule; +} diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..89605f0 --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,90 @@ +import { createPublicKey, type JsonWebKey } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { + CanActivate, ExecutionContext, ForbiddenException, Injectable, SetMetadata, UnauthorizedException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { AuthGuard, PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; + +import type { RootlineApiConfig } from "./config.js"; + +export const PUBLIC_ROUTE = "rootline:public"; +export const REQUIRED_PERMISSIONS = "rootline:permissions"; +export const Public = () => SetMetadata(PUBLIC_ROUTE, true); +export const Permissions = (...permissions: string[]) => SetMetadata(REQUIRED_PERMISSIONS, permissions); + +export interface AuthUser { + sub: string; + permissions: string[]; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function decodeHeader(token: string): { alg?: string; kid?: string } { + try { + return JSON.parse(Buffer.from(token.split(".")[0] ?? "", "base64url").toString("utf8")) as { alg?: string; kid?: string }; + } catch { + throw new UnauthorizedException("Invalid bearer token."); + } +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(config: RootlineApiConfig) { + const raw = JSON.parse(readFileSync(config.jwksPath, "utf8")) as { keys: Array }; + const keys = new Map(raw.keys.map((key) => [key.kid, key])); + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + algorithms: ["RS256"], + issuer: config.issuer, + audience: config.audience, + secretOrKeyProvider: (_request: unknown, token: string, done: (error: Error | null, key?: string) => void) => { + try { + const header = decodeHeader(token); + if (header.alg !== "RS256" || !header.kid) return done(new Error("Unsupported signing key.")); + const jwk = keys.get(header.kid); + if (!jwk || (jwk.alg && jwk.alg !== "RS256")) return done(new Error("Unknown signing key.")); + const pem = createPublicKey({ key: jwk, format: "jwk" }).export({ type: "spki", format: "pem" }).toString(); + done(null, pem); + } catch (error) { + done(error instanceof Error ? error : new Error("Invalid token.")); + } + }, + }); + } + + validate(payload: Record): AuthUser { + if (typeof payload.sub !== "string" || payload.sub.length === 0) throw new UnauthorizedException("Token subject is required."); + return { sub: payload.sub, permissions: stringArray(payload.permissions) }; + } +} + +@Injectable() +export class JwtAuthGuard extends AuthGuard("jwt") { + constructor(private readonly reflector: Reflector) { super(); } + canActivate(context: ExecutionContext) { + if (this.reflector.getAllAndOverride(PUBLIC_ROUTE, [context.getHandler(), context.getClass()])) return true; + return super.canActivate(context); + } + handleRequest(error: unknown, user: TUser | false | null): TUser { + if (error || !user) throw new UnauthorizedException("A valid Rootline access token is required."); + return user; + } +} + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + canActivate(context: ExecutionContext): boolean { + if (this.reflector.getAllAndOverride(PUBLIC_ROUTE, [context.getHandler(), context.getClass()])) return true; + const required = this.reflector.getAllAndOverride(REQUIRED_PERMISSIONS, [context.getHandler(), context.getClass()]); + if (!required?.length) throw new ForbiddenException("Endpoint permission metadata is required."); + const request = context.switchToHttp().getRequest<{ user?: AuthUser }>(); + if (!required.some((permission) => request.user?.permissions.includes(permission))) throw new ForbiddenException("Required permission is missing."); + return true; + } +} diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts new file mode 100644 index 0000000..6f38d8a --- /dev/null +++ b/apps/api/src/bootstrap.ts @@ -0,0 +1,17 @@ +import { ValidationPipe, type INestApplication } from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; +import { json } from "express"; + +import { createAppModule } from "./app.module.js"; +import type { RootlineApiConfig } from "./config.js"; +import { SafeExceptionFilter } from "./safe-exception.filter.js"; + +export async function createApplication(config: RootlineApiConfig): Promise { + const app = await NestFactory.create(createAppModule(config), { bodyParser: false }); + app.use(json({ limit: 256 * 1024, strict: true })); + app.useGlobalFilters(new SafeExceptionFilter()); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + app.enableShutdownHooks(); + await app.init(); + return app; +} diff --git a/apps/api/src/code-point-length.validator.ts b/apps/api/src/code-point-length.validator.ts new file mode 100644 index 0000000..f69f2fd --- /dev/null +++ b/apps/api/src/code-point-length.validator.ts @@ -0,0 +1,26 @@ +import { codePointLength } from "@rootline/contracts"; +import { buildMessage, type ValidationOptions, ValidateBy } from "class-validator"; + +export function CodePointLength( + min: number, + max: number, + validationOptions?: ValidationOptions, +): PropertyDecorator { + return ValidateBy( + { + name: "codePointLength", + constraints: [min, max], + validator: { + validate: (value): boolean => + typeof value === "string" + && codePointLength(value) >= min + && codePointLength(value) <= max, + defaultMessage: buildMessage( + (eachPrefix) => `${eachPrefix}$property must contain between $constraint1 and $constraint2 Unicode code points`, + validationOptions, + ), + }, + }, + validationOptions, + ); +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..ce7bf2e --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; + +export interface RootlineApiConfig { + databaseUrl: string; + issuer: string; + audience: string; + jwksPath: string; + rateLimit: number; + port: number; +} + +function required(value: string | undefined, name: string): string { + if (!value?.trim()) throw new Error(`${name} is required; Rootline API refuses to start without it.`); + return value; +} + +export function loadConfig(overrides: Partial = {}): RootlineApiConfig { + const config = { + databaseUrl: overrides.databaseUrl ?? required(process.env.DATABASE_URL, "DATABASE_URL"), + issuer: overrides.issuer ?? required(process.env.JWT_ISSUER, "JWT_ISSUER"), + audience: overrides.audience ?? required(process.env.JWT_AUDIENCE, "JWT_AUDIENCE"), + jwksPath: overrides.jwksPath ?? required(process.env.JWT_JWKS_PATH, "JWT_JWKS_PATH"), + rateLimit: overrides.rateLimit ?? Number(process.env.RATE_LIMIT_PER_MINUTE ?? 60), + port: overrides.port ?? Number(process.env.PORT ?? 3000), + }; + if (!Number.isInteger(config.rateLimit) || config.rateLimit < 1) throw new Error("RATE_LIMIT_PER_MINUTE must be a positive integer."); + const jwks = JSON.parse(readFileSync(config.jwksPath, "utf8")) as { keys?: unknown[] }; + if (!Array.isArray(jwks.keys) || jwks.keys.length === 0) throw new Error("JWT_JWKS_PATH must contain at least one static signing key."); + return config; +} diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts new file mode 100644 index 0000000..088439e --- /dev/null +++ b/apps/api/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { Public } from "./auth.js"; + +@ApiTags("health") +@Controller() +export class HealthController { + @Public() + @Get("healthz") + @ApiOperation({ summary: "Readiness/liveness probe" }) + @ApiResponse({ status: 200 }) + health() { return { status: "ok", buildId: process.env.ROOTLINE_BUILD_ID || "development" }; } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..1708dc9 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,2 @@ +export { createApplication } from "./bootstrap.js"; +export { loadConfig } from "./config.js"; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..e68f913 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,7 @@ +import "reflect-metadata"; +import { createApplication } from "./bootstrap.js"; +import { loadConfig } from "./config.js"; + +const config = loadConfig(); +const app = await createApplication(config); +await app.listen(config.port, "0.0.0.0"); diff --git a/apps/api/src/prisma.service.ts b/apps/api/src/prisma.service.ts new file mode 100644 index 0000000..7cebee1 --- /dev/null +++ b/apps/api/src/prisma.service.ts @@ -0,0 +1,17 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor(databaseUrl: string) { + super({ datasources: { db: { url: databaseUrl } } }); + } + + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/apps/api/src/rate-limit.guard.ts b/apps/api/src/rate-limit.guard.ts new file mode 100644 index 0000000..1bbc4ac --- /dev/null +++ b/apps/api/src/rate-limit.guard.ts @@ -0,0 +1,31 @@ +import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +import { PUBLIC_ROUTE, type AuthUser } from "./auth.js"; +import type { RootlineApiConfig } from "./config.js"; + +@Injectable() +export class UserRateLimitGuard implements CanActivate { + private readonly requests = new Map(); + private lastSweep = 0; + constructor(private readonly reflector: Reflector, private readonly config: RootlineApiConfig) {} + canActivate(context: ExecutionContext): boolean { + if (this.reflector.getAllAndOverride(PUBLIC_ROUTE, [context.getHandler(), context.getClass()])) return true; + const user = context.switchToHttp().getRequest<{ user?: AuthUser }>().user; + if (!user) return true; + const now = Date.now(); + if (now - this.lastSweep >= 60_000) { + for (const [subject, timestamps] of this.requests) { + const fresh = timestamps.filter((value) => value > now - 60_000); + if (fresh.length === 0) this.requests.delete(subject); + else this.requests.set(subject, fresh); + } + this.lastSweep = now; + } + const active = (this.requests.get(user.sub) ?? []).filter((value) => value > now - 60_000); + if (active.length >= this.config.rateLimit) throw new HttpException("Per-user request limit exceeded.", HttpStatus.TOO_MANY_REQUESTS); + active.push(now); + this.requests.set(user.sub, active); + return true; + } +} diff --git a/apps/api/src/safe-exception.filter.ts b/apps/api/src/safe-exception.filter.ts new file mode 100644 index 0000000..32a2093 --- /dev/null +++ b/apps/api/src/safe-exception.filter.ts @@ -0,0 +1,29 @@ +import { ArgumentsHost, Catch, HttpException, HttpStatus, Logger, type ExceptionFilter } from "@nestjs/common"; +import type { Response } from "express"; + +function transportStatus(exception: unknown): number | undefined { + if (typeof exception !== "object" || exception === null || !("status" in exception)) return undefined; + const status = (exception as { status?: unknown }).status; + return typeof status === "number" && status >= 400 && status <= 599 ? status : undefined; +} + +/** Prevent exception objects, request bodies, tokens, and paths from entering production logs. */ +@Catch() +export class SafeExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(SafeExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + if (exception instanceof HttpException) { + response.status(exception.getStatus()).json(exception.getResponse()); + return; + } + const status = transportStatus(exception); + if (status === HttpStatus.PAYLOAD_TOO_LARGE) { + response.status(status).json({ statusCode: status, message: "Request body exceeds 256 KiB." }); + return; + } + this.logger.error("Unhandled API failure."); + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ statusCode: 500, message: "Internal server error." }); + } +} diff --git a/apps/api/src/sync.controller.ts b/apps/api/src/sync.controller.ts new file mode 100644 index 0000000..6a91de9 --- /dev/null +++ b/apps/api/src/sync.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Delete, HttpCode, Inject, Post, ValidationPipe } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; + +import { Permissions, type AuthUser } from "./auth.js"; +import { CurrentUser } from "./user.decorator.js"; +import { DeleteAccountDataDto, SyncRequestDto } from "./sync.dto.js"; +import { SyncService } from "./sync.service.js"; + +@ApiTags("profile-sync") +@ApiBearerAuth() +@Permissions("rootline:profiles:sync") +@Controller("v1") +export class SyncController { + constructor(@Inject(SyncService) private readonly syncService: SyncService) {} + + @Post("sync") + @HttpCode(200) + @ApiOperation({ summary: "Commit profile mutations and retrieve a revision delta" }) + @ApiResponse({ status: 200, description: "Profile mutation receipts and ordered delta" }) + sync( + @CurrentUser() user: AuthUser, + @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true, expectedType: SyncRequestDto })) dto: SyncRequestDto, + ) { + return this.syncService.sync(user.sub, dto); + } + + @Delete("account-data") + @HttpCode(200) + @ApiOperation({ summary: "Delete hosted profile data and rotate the sync epoch" }) + @ApiResponse({ status: 200, description: "New epoch required by all devices" }) + deleteAccountData( + @CurrentUser() user: AuthUser, + @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true, expectedType: DeleteAccountDataDto })) dto: DeleteAccountDataDto, + ) { + return this.syncService.deleteAccountData(user.sub, dto); + } +} diff --git a/apps/api/src/sync.dto.ts b/apps/api/src/sync.dto.ts new file mode 100644 index 0000000..4a394ac --- /dev/null +++ b/apps/api/src/sync.dto.ts @@ -0,0 +1,45 @@ +import { Type } from "class-transformer"; +import { PROFILE_LIMITS } from "@rootline/contracts"; +import { + ArrayMaxSize, IsArray, IsDateString, IsIn, IsInt, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested, +} from "class-validator"; +import { CodePointLength } from "./code-point-length.validator.js"; + +export class SyncProfileDto { + @IsString() @MinLength(1) @MaxLength(128) id!: string; + @IsString() @CodePointLength(PROFILE_LIMITS.name.min, PROFILE_LIMITS.name.max) name!: string; + @IsString() @CodePointLength(PROFILE_LIMITS.path.min, PROFILE_LIMITS.path.max) sourcePath!: string; + @IsString() @CodePointLength(PROFILE_LIMITS.path.min, PROFILE_LIMITS.path.max) targetPath!: string; + @IsArray() + @ArrayMaxSize(PROFILE_LIMITS.exclusions.max) + @IsString({ each: true }) + @CodePointLength(PROFILE_LIMITS.exclusions.pattern.min, PROFILE_LIMITS.exclusions.pattern.max, { each: true }) + exclusions!: string[]; + @IsOptional() @IsDateString() createdAt?: string; + @IsOptional() @IsDateString() updatedAt?: string; + @IsOptional() @IsIn(["additive"]) syncMode?: "additive"; + @IsOptional() @IsInt() schemaVersion?: number; + @IsOptional() @IsString() @MaxLength(128) revision?: string; + @IsOptional() @IsDateString() deletedAt?: string | null; +} + +export class ProfileMutationDto { + @IsUUID() mutationId!: string; + @IsOptional() @IsIn(["upsert", "delete"]) kind?: "upsert" | "delete"; + @IsOptional() @IsIn(["upsert", "delete"]) type?: "upsert" | "delete"; + @IsOptional() @IsDateString() occurredAt?: string; + @IsOptional() @ValidateNested() @Type(() => SyncProfileDto) profile?: SyncProfileDto; + @IsOptional() @IsString() @MinLength(1) @MaxLength(128) profileId?: string; +} + +export class SyncRequestDto { + @IsString() @MinLength(1) @MaxLength(128) deviceId!: string; + @IsOptional() @IsUUID() epoch?: string; + @IsOptional() @IsUUID() accountEpoch?: string | null; + @IsOptional() @IsString() @MaxLength(512) cursor?: string; + @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => ProfileMutationDto) mutations!: ProfileMutationDto[]; +} + +export class DeleteAccountDataDto { + @IsUUID() epoch!: string; +} diff --git a/apps/api/src/sync.service.ts b/apps/api/src/sync.service.ts new file mode 100644 index 0000000..d114d7a --- /dev/null +++ b/apps/api/src/sync.service.ts @@ -0,0 +1,271 @@ +import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { ROOTLINE_ERROR_CODES } from "@rootline/contracts"; +import { createHash, randomUUID } from "node:crypto"; + +import { PrismaService } from "./prisma.service.js"; +import type { DeleteAccountDataDto, SyncProfileDto, SyncRequestDto } from "./sync.dto.js"; + +const RECEIPT_TTL_MS = 90 * 24 * 60 * 60 * 1000; +const DELTA_RECORD_LIMIT = 100; +const DELTA_BODY_BUDGET = 1024 * 1024; +const PUBLIC_PROFILE_TIMESTAMP = "1970-01-01T00:00:00.000Z"; + +interface NormalizedProfile { + id: string; + name: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; + createdAt: string; + updatedAt: string; + syncMode?: "additive"; +} + +type NormalizedMutation = + | { mutationId: string; kind: "upsert"; profile: NormalizedProfile; occurredAt: string; publicRevision?: string } + | { mutationId: string; kind: "delete"; profileId: string; occurredAt: string }; + +interface NormalizedSyncRequest { + deviceId: string; + epoch: string | null; + cursor?: string; + mutations: NormalizedMutation[]; +} + +function encodeCursor(epoch: string, revision: bigint): string { + return Buffer.from(JSON.stringify({ epoch, revision: revision.toString() }), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string | undefined, epoch: string): bigint { + if (!cursor) return 0n; + try { + const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { epoch?: unknown; revision?: unknown }; + if (typeof value.epoch !== "string" || typeof value.revision !== "string" || !/^\d+$/.test(value.revision)) throw new Error(); + if (value.epoch !== epoch) throw new ConflictException({ code: ROOTLINE_ERROR_CODES.RESET_REQUIRED, epoch }); + return BigInt(value.revision); + } catch (error) { + if (error instanceof ConflictException) throw error; + throw new BadRequestException("Cursor is invalid."); + } +} + +function json(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} + +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (typeof value === "object" && value !== null) { + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonical(item)])); + } + return value; +} + +function mutationHash(mutation: NormalizedMutation): string { + return createHash("sha256").update(JSON.stringify(canonical(mutation))).digest("hex"); +} + +function normalizeProfile(profile: SyncProfileDto, publicContract: boolean): NormalizedProfile { + if (publicContract) { + if (profile.schemaVersion !== 1) throw new BadRequestException({ code: "SCHEMA_UNSUPPORTED" }); + if (profile.revision === undefined || profile.deletedAt !== null || profile.createdAt !== undefined || profile.updatedAt !== undefined || profile.syncMode !== undefined) { + throw new BadRequestException("A public v1 upsert requires revision, deletedAt null, and no internal timestamps."); + } + return { + id: profile.id, + name: profile.name, + sourcePath: profile.sourcePath, + targetPath: profile.targetPath, + exclusions: profile.exclusions, + createdAt: PUBLIC_PROFILE_TIMESTAMP, + updatedAt: PUBLIC_PROFILE_TIMESTAMP, + syncMode: "additive", + }; + } + if (!profile.createdAt || !profile.updatedAt || profile.schemaVersion !== undefined || profile.revision !== undefined || profile.deletedAt !== undefined) { + throw new BadRequestException("An internal profile requires timestamps and cannot mix public v1 fields."); + } + return { + id: profile.id, + name: profile.name, + sourcePath: profile.sourcePath, + targetPath: profile.targetPath, + exclusions: profile.exclusions, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + ...(profile.syncMode ? { syncMode: profile.syncMode } : {}), + }; +} + +function normalizeRequest(dto: SyncRequestDto): NormalizedSyncRequest { + const publicContract = dto.accountEpoch !== undefined || dto.mutations.some((mutation) => mutation.type !== undefined); + if (publicContract && dto.epoch !== undefined) throw new BadRequestException("Public and internal epoch fields cannot be mixed."); + if (!publicContract && (!dto.epoch || dto.accountEpoch !== undefined)) throw new BadRequestException("An epoch is required."); + const mutations = dto.mutations.map((mutation): NormalizedMutation => { + if (publicContract) { + if (!mutation.type || mutation.kind !== undefined || mutation.occurredAt !== undefined) { + throw new BadRequestException("A public mutation requires only its type discriminant."); + } + if (mutation.type === "upsert") { + if (!mutation.profile || mutation.profileId || mutation.profile.revision === undefined) { + throw new BadRequestException("A public upsert requires only a revisioned profile."); + } + return { + mutationId: mutation.mutationId, + kind: "upsert", + profile: normalizeProfile(mutation.profile, true), + occurredAt: PUBLIC_PROFILE_TIMESTAMP, + publicRevision: mutation.profile.revision, + }; + } + if (!mutation.profileId || mutation.profile) throw new BadRequestException("A delete requires only profileId."); + return { mutationId: mutation.mutationId, kind: "delete", profileId: mutation.profileId, occurredAt: PUBLIC_PROFILE_TIMESTAMP }; + } + if (!mutation.kind || mutation.type !== undefined || !mutation.occurredAt) { + throw new BadRequestException("An internal mutation requires kind and occurredAt."); + } + if (mutation.kind === "upsert") { + if (!mutation.profile || mutation.profileId) throw new BadRequestException("An upsert requires only profile."); + return { mutationId: mutation.mutationId, kind: "upsert", profile: normalizeProfile(mutation.profile, false), occurredAt: mutation.occurredAt }; + } + if (!mutation.profileId || mutation.profile) throw new BadRequestException("A delete requires only profileId."); + return { mutationId: mutation.mutationId, kind: "delete", profileId: mutation.profileId, occurredAt: mutation.occurredAt }; + }); + return { + deviceId: dto.deviceId, + epoch: publicContract ? dto.accountEpoch ?? null : dto.epoch!, + ...(dto.cursor === undefined ? {} : { cursor: dto.cursor }), + mutations, + }; +} + +function publicProfile(record: Prisma.JsonValue): Record | null { + if (typeof record !== "object" || record === null || Array.isArray(record)) return null; + const value = record as Record; + const profile = value.profile; + if (typeof profile !== "object" || profile === null || Array.isArray(profile)) return null; + const source = profile as Record; + if (typeof source.id !== "string" || typeof source.name !== "string" || typeof source.sourcePath !== "string" + || typeof source.targetPath !== "string" || !Array.isArray(source.exclusions) || typeof value.revision !== "number") return null; + return { + id: source.id, + schemaVersion: 1, + name: source.name, + sourcePath: source.sourcePath, + targetPath: source.targetPath, + exclusions: source.exclusions, + revision: String(value.revision), + deletedAt: value.kind === "tombstone" && typeof value.deletedAt === "string" ? value.deletedAt : null, + }; +} + +@Injectable() +export class SyncService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async sync(subject: string, dto: SyncRequestDto) { + const request = normalizeRequest(dto); + return this.prisma.$transaction(async (tx) => { + await tx.userSyncState.upsert({ where: { subject }, update: {}, create: { subject, epoch: request.epoch ?? randomUUID() } }); + await tx.$queryRaw`SELECT "subject" FROM "user_sync_state" WHERE "subject" = ${subject} FOR UPDATE`; + const state = await tx.userSyncState.findUniqueOrThrow({ where: { subject } }); + if (request.epoch !== null && state.epoch !== request.epoch) { + throw new ConflictException({ code: ROOTLINE_ERROR_CODES.RESET_REQUIRED, epoch: state.epoch }); + } + const requestedRevision = decodeCursor(request.cursor, state.epoch); + if (requestedRevision > state.revision) throw new BadRequestException("Cursor revision is ahead of the server."); + await tx.mutationReceipt.deleteMany({ where: { expiresAt: { lte: new Date() } } }); + + let revision = state.revision; + const receipts: Array<{ mutationId: string; revision: number }> = []; + for (const mutation of request.mutations) { + const hash = mutationHash(mutation); + const prior = await tx.mutationDedup.findUnique({ where: { subject_mutationId: { subject, mutationId: mutation.mutationId } } }); + if (prior) { + if (prior.mutationHash !== hash) throw new ConflictException("A mutation ID cannot be reused for different profile data."); + receipts.push({ mutationId: mutation.mutationId, revision: Number(prior.revision) }); + continue; + } + revision += 1n; + const committedAt = new Date(); + const existing = mutation.kind === "delete" + ? await tx.profileRecord.findUnique({ where: { subject_profileId: { subject, profileId: mutation.profileId } } }) + : null; + const retainedProfile = existing?.profile && typeof existing.profile === "object" && !Array.isArray(existing.profile) + ? existing.profile + : null; + const record = mutation.kind === "upsert" + ? { kind: "profile", profile: mutation.profile!, revision: Number(revision) } + : { + kind: "tombstone", profileId: mutation.profileId!, deletedAt: committedAt.toISOString(), revision: Number(revision), + ...(retainedProfile ? { profile: retainedProfile } : {}), + }; + await tx.profileRecord.upsert({ + where: { subject_profileId: { subject, profileId: mutation.kind === "upsert" ? mutation.profile!.id : mutation.profileId! } }, + create: { + subject, profileId: mutation.kind === "upsert" ? mutation.profile!.id : mutation.profileId!, kind: mutation.kind === "upsert" ? "profile" : "tombstone", + profile: mutation.kind === "upsert" ? json(mutation.profile) : retainedProfile ? json(retainedProfile) : Prisma.JsonNull, + deletedAt: mutation.kind === "delete" ? committedAt : null, revision, lastDeviceId: request.deviceId, committedAt, + }, + update: { + kind: mutation.kind === "upsert" ? "profile" : "tombstone", + profile: mutation.kind === "upsert" ? json(mutation.profile) : retainedProfile ? json(retainedProfile) : Prisma.JsonNull, + deletedAt: mutation.kind === "delete" ? committedAt : null, revision, lastDeviceId: request.deviceId, committedAt, + }, + }); + await tx.syncChange.create({ data: { subject, revision, record: json(record), committedAt } }); + await tx.mutationDedup.create({ + data: { subject, mutationId: mutation.mutationId, mutationHash: hash, revision, createdAt: committedAt }, + }); + await tx.mutationReceipt.create({ + data: { subject, mutationId: mutation.mutationId, mutationHash: hash, revision, expiresAt: new Date(committedAt.getTime() + RECEIPT_TTL_MS) }, + }); + receipts.push({ mutationId: mutation.mutationId, revision: Number(revision) }); + } + if (revision !== state.revision) await tx.userSyncState.update({ where: { subject }, data: { revision } }); + const candidates = await tx.syncChange.findMany({ + where: { subject, revision: { gt: requestedRevision, lte: revision } }, orderBy: { revision: "asc" }, + take: DELTA_RECORD_LIMIT + 1, select: { revision: true, record: true }, + }); + const records: Prisma.JsonValue[] = []; + let responseBytes = 0; + let cursorRevision = requestedRevision; + for (const change of candidates.slice(0, DELTA_RECORD_LIMIT)) { + const bytes = Buffer.byteLength(JSON.stringify(change.record), "utf8"); + if (records.length > 0 && responseBytes + bytes > DELTA_BODY_BUDGET) break; + records.push(change.record); + responseBytes += bytes; + cursorRevision = change.revision; + } + return { + epoch: state.epoch, + accountEpoch: state.epoch, + cursor: encodeCursor(state.epoch, cursorRevision), + hasMore: cursorRevision < revision, + records, + receipts, + acknowledgedMutationIds: receipts.map((receipt) => receipt.mutationId), + profiles: records.map(publicProfile).filter((profile): profile is Record => profile !== null), + }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + + async deleteAccountData(subject: string, dto: DeleteAccountDataDto) { + return this.prisma.$transaction(async (tx) => { + await tx.userSyncState.upsert({ where: { subject }, update: {}, create: { subject, epoch: dto.epoch } }); + await tx.$queryRaw`SELECT "subject" FROM "user_sync_state" WHERE "subject" = ${subject} FOR UPDATE`; + const state = await tx.userSyncState.findUniqueOrThrow({ where: { subject } }); + if (state.epoch !== dto.epoch) { + throw new ConflictException({ code: ROOTLINE_ERROR_CODES.RESET_REQUIRED, epoch: state.epoch }); + } + const epoch = randomUUID(); + await tx.profileRecord.deleteMany({ where: { subject } }); + await tx.syncChange.deleteMany({ where: { subject } }); + await tx.mutationReceipt.deleteMany({ where: { subject } }); + await tx.mutationDedup.deleteMany({ where: { subject } }); + await tx.userSyncState.update({ where: { subject }, data: { epoch, revision: 0n } }); + return { epoch }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } +} diff --git a/apps/api/src/testing.ts b/apps/api/src/testing.ts new file mode 100644 index 0000000..d60ddf8 --- /dev/null +++ b/apps/api/src/testing.ts @@ -0,0 +1,30 @@ +import type { INestApplication } from "@nestjs/common"; +import type { Server } from "node:http"; + +import { createApplication } from "./bootstrap.js"; +import { loadConfig, type RootlineApiConfig } from "./config.js"; +import { PrismaService } from "./prisma.service.js"; + +export interface TestApplication { + app: INestApplication; + server: Server; + resetDatabase(): Promise; + close(): Promise; +} + +export async function createTestApplication(overrides: Partial): Promise { + const app = await createApplication(loadConfig(overrides)); + const prisma = app.get(PrismaService); + return { + app, + server: app.getHttpServer() as Server, + async resetDatabase() { + await prisma.mutationReceipt.deleteMany(); + await prisma.mutationDedup.deleteMany(); + await prisma.syncChange.deleteMany(); + await prisma.profileRecord.deleteMany(); + await prisma.userSyncState.deleteMany(); + }, + close: () => app.close(), + }; +} diff --git a/apps/api/src/user.decorator.ts b/apps/api/src/user.decorator.ts new file mode 100644 index 0000000..7899f29 --- /dev/null +++ b/apps/api/src/user.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, type ExecutionContext } from "@nestjs/common"; +import type { AuthUser } from "./auth.js"; + +export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext): AuthUser => + context.switchToHttp().getRequest<{ user: AuthUser }>().user, +); diff --git a/apps/api/test/sync.e2e.spec.ts b/apps/api/test/sync.e2e.spec.ts new file mode 100644 index 0000000..fd07c94 --- /dev/null +++ b/apps/api/test/sync.e2e.spec.ts @@ -0,0 +1,421 @@ +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import request from "supertest"; +import { Logger } from "@nestjs/common"; +import { PrismaClient } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import { createTestApplication, type TestApplication } from "../src/testing.js"; + +const ISSUER = "https://auth.baole.space/application/o/rootline/"; +const AUDIENCE = "rootline-desktop"; +const EPOCH = "00000000-0000-4000-8000-000000000001"; +const PROFILE_ID = "profile-existing-task3"; +const execFileAsync = promisify(execFile); + +describe("Rootline hosted sync (real PostgreSQL)", () => { + let fixture: TestApplication; + let directory: string; + let privateKey: CryptoKey; + let apiUrl: string; + let postgres: PrismaClient; + + beforeAll(async () => { + if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL must point to a real PostgreSQL test database"); + directory = await mkdtemp(join(tmpdir(), "rootline-jwks-")); + const keys = await generateKeyPair("RS256", { extractable: true }); + privateKey = keys.privateKey; + const jwk = await exportJWK(keys.publicKey); + Object.assign(jwk, { kid: "rootline-test", alg: "RS256", use: "sig" }); + const jwksPath = join(directory, "jwks.json"); + await writeFile(jwksPath, JSON.stringify({ keys: [jwk] }), "utf8"); + fixture = await createTestApplication({ + databaseUrl: process.env.DATABASE_URL, + issuer: ISSUER, + audience: AUDIENCE, + jwksPath, + rateLimit: 60, + }); + await fixture.app.listen(0, "127.0.0.1"); + const address = fixture.server.address(); + if (!address || typeof address === "string") throw new Error("Test API did not bind a TCP port"); + apiUrl = `http://127.0.0.1:${address.port}`; + postgres = new PrismaClient({ + datasources: { db: { url: process.env.DATABASE_URL } }, + }); + await postgres.$connect(); + await fixture.resetDatabase(); + }); + + afterAll(async () => { + await postgres?.$disconnect(); + await fixture?.close(); + if (directory) await rm(directory, { recursive: true, force: true }); + }); + + async function token( + sub: string, + overrides: { issuer?: string; audience?: string; permissions?: string[] } = {}, + ): Promise { + return new SignJWT({ permissions: overrides.permissions ?? ["rootline:profiles:sync"] }) + .setProtectedHeader({ alg: "RS256", kid: "rootline-test" }) + .setSubject(sub) + .setIssuer(overrides.issuer ?? ISSUER) + .setAudience(overrides.audience ?? AUDIENCE) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + } + + function mutation(id: string, name: string, mutationId: string) { + return { + mutationId, + kind: "upsert", + occurredAt: "2026-08-15T00:00:00.000Z", + profile: { + id, + name, + sourcePath: "/Users/alice/source", + targetPath: "D:\\backups\\alice", + exclusions: [".git"], + createdAt: "2026-08-15T00:00:00.000Z", + updatedAt: "2026-08-15T00:00:00.000Z", + syncMode: "additive", + }, + }; + } + + test("keeps health public and rejects wrong issuer, audience, or permission", async () => { + await request(fixture.server).get("/healthz").expect(200, { status: "ok", buildId: "development" }); + const body = { deviceId: "device-a", epoch: EPOCH, mutations: [] }; + await request(fixture.server).post("/v1/sync").send(body).expect(401); + await request(fixture.server) + .post("/v1/sync").set("Authorization", `Bearer ${await token("auth-a", { issuer: "https://evil.invalid/" })}`).send(body).expect(401); + await request(fixture.server) + .post("/v1/sync").set("Authorization", `Bearer ${await token("auth-a", { audience: "another-client" })}`).send(body).expect(401); + await request(fixture.server) + .post("/v1/sync").set("Authorization", `Bearer ${await token("auth-a", { permissions: [] })}`).send(body).expect(403); + }); + + test("is tenant scoped, idempotent, and assigns LWW by commit arrival", async () => { + const alice = await token("tenant-alice"); + const bob = await token("tenant-bob"); + const first = mutation(PROFILE_ID, "First", "00000000-0000-4000-8000-000000000201"); + const laterArrivalWithOlderClock = { + ...mutation(PROFILE_ID, "Arrival wins", "00000000-0000-4000-8000-000000000202"), + occurredAt: "2020-01-01T00:00:00.000Z", + profile: { ...mutation(PROFILE_ID, "Arrival wins", "x").profile, updatedAt: "2020-01-01T00:00:00.000Z" }, + }; + + const firstResponse = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${alice}`) + .send({ deviceId: "alice-1", epoch: EPOCH, mutations: [first] }).expect(200); + expect(firstResponse.body.receipts).toEqual([{ mutationId: first.mutationId, revision: 1 }]); + const replay = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${alice}`) + .send({ deviceId: "alice-1", epoch: EPOCH, mutations: [first] }).expect(200); + expect(replay.body.receipts).toEqual(firstResponse.body.receipts); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${alice}`) + .send({ deviceId: "alice-1", epoch: EPOCH, mutations: [{ ...first, profile: { ...first.profile, name: "Collision" } }] }).expect(409); + + const arrived = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${alice}`) + .send({ deviceId: "alice-2", epoch: EPOCH, cursor: firstResponse.body.cursor, mutations: [laterArrivalWithOlderClock] }).expect(200); + expect(arrived.body.records).toEqual([expect.objectContaining({ kind: "profile", revision: 2, profile: expect.objectContaining({ name: "Arrival wins" }) })]); + + const isolated = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${bob}`) + .send({ deviceId: "bob-1", epoch: EPOCH, mutations: [] }).expect(200); + expect(isolated.body.records).toEqual([]); + }); + + test("accepts and returns the documented public v1 sync contract alongside the paginated protocol", async () => { + const auth = await token("public-contract-user"); + const mutationId = "00000000-0000-4000-8000-000000000205"; + const profile = { + id: "public-profile", + schemaVersion: 1, + name: "Public profile", + sourcePath: "/Users/public/source", + targetPath: "D:\\public-target", + exclusions: ["generated/**/cache"], + revision: "0", + deletedAt: null, + }; + const created = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: null, + cursor: "", + mutations: [{ mutationId, type: "upsert", profile }], + }).expect(200); + + expect(created.body).toMatchObject({ + accountEpoch: expect.any(String), + acknowledgedMutationIds: [mutationId], + profiles: [{ ...profile, revision: "1" }], + }); + expect(created.body).toMatchObject({ epoch: created.body.accountEpoch, receipts: [{ mutationId }] }); + const deviceColumns = await postgres.$queryRaw>` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'profile_record' AND column_name = 'last_device_id' + `; + expect(deviceColumns).toEqual([{ column_name: "last_device_id" }]); + const storedDevice = await postgres.$queryRaw>` + SELECT last_device_id FROM profile_record + WHERE subject = 'public-contract-user' AND profile_id = 'public-profile' + `; + expect(storedDevice).toEqual([{ last_device_id: "public-device" }]); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: created.body.accountEpoch, + cursor: created.body.cursor, + mutations: [{ mutationId, type: "upsert", profile: { ...profile, revision: "different-payload" } }], + }).expect(409); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: created.body.accountEpoch, + cursor: created.body.cursor, + mutations: [{ + mutationId: "00000000-0000-4000-8000-000000000207", + type: "upsert", + profile: { ...profile, syncMode: "additive" }, + }], + }).expect(400); + const privacyLog = vi.spyOn(Logger.prototype, "error"); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: created.body.accountEpoch, + cursor: created.body.cursor, + mutations: [{ + mutationId: "00000000-0000-4000-8000-000000000208", + type: "upsert", + profile: { + ...profile, + sourcePath: "/private/secret-source-that-must-not-be-logged", + directoryTree: ["private", "secret"], + runHistory: [{ result: "created" }], + }, + }], + }).expect(400); + expect(privacyLog).not.toHaveBeenCalled(); + privacyLog.mockRestore(); + + const deleteMutationId = "00000000-0000-4000-8000-000000000206"; + const deleted = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: created.body.accountEpoch, + cursor: created.body.cursor, + mutations: [{ mutationId: deleteMutationId, type: "delete", profileId: profile.id }], + }).expect(200); + expect(deleted.body.acknowledgedMutationIds).toEqual([deleteMutationId]); + expect(deleted.body.profiles).toEqual([ + expect.objectContaining({ ...profile, revision: "2", deletedAt: expect.any(String) }), + ]); + }); + + test("keeps mutation idempotency for the account epoch after the 90-day receipt expires", async () => { + const subject = "durable-dedup-user"; + const auth = await token(subject); + const original = mutation(PROFILE_ID, "Original", "00000000-0000-4000-8000-000000000211"); + const newer = mutation(PROFILE_ID, "Newer", "00000000-0000-4000-8000-000000000212"); + + const revisionOne = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "dedup-a", epoch: EPOCH, mutations: [original] }).expect(200); + const revisionTwo = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "dedup-b", epoch: EPOCH, cursor: revisionOne.body.cursor, mutations: [newer] }).expect(200); + + await postgres.$executeRaw` + UPDATE "mutation_receipt" + SET "expires_at" = NOW() - INTERVAL '1 day' + WHERE "subject" = ${subject} AND "mutation_id" = ${original.mutationId}::uuid + `; + const replay = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "dedup-a", epoch: EPOCH, cursor: revisionTwo.body.cursor, mutations: [original] }).expect(200); + + expect(replay.body.receipts).toEqual([{ mutationId: original.mutationId, revision: 1 }]); + expect(replay.body.records).toEqual([]); + const state = await postgres.$queryRaw>` + SELECT "revision" FROM "user_sync_state" WHERE "subject" = ${subject} + `; + const profile = await postgres.$queryRaw>` + SELECT "profile" FROM "profile_record" WHERE "subject" = ${subject} AND "profile_id" = ${PROFILE_ID} + `; + const changes = await postgres.$queryRaw>` + SELECT COUNT(*)::bigint AS "count" FROM "sync_change" WHERE "subject" = ${subject} + `; + const receipts = await postgres.$queryRaw>` + SELECT COUNT(*)::bigint AS "count" FROM "mutation_receipt" + WHERE "subject" = ${subject} AND "mutation_id" = ${original.mutationId}::uuid + `; + expect(state[0]?.revision).toBe(2n); + expect(profile[0]?.profile.name).toBe("Newer"); + expect(changes[0]?.count).toBe(2n); + expect(receipts[0]?.count).toBe(0n); + + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "dedup-a", + epoch: EPOCH, + cursor: revisionTwo.body.cursor, + mutations: [{ ...original, profile: { ...original.profile, name: "Collision" } }], + }).expect(409); + + await request(fixture.server).delete("/v1/account-data").set("Authorization", `Bearer ${auth}`) + .send({ epoch: EPOCH }).expect(200); + const ledger = await postgres.$queryRaw>` + SELECT COUNT(*)::bigint AS "count" FROM "mutation_dedup" WHERE "subject" = ${subject} + `; + expect(ledger[0]?.count).toBe(0n); + }); + + test("returns cursor deltas and tombstones without resurrecting profiles", async () => { + const auth = await token("delta-user"); + const upsert = mutation(PROFILE_ID, "Delta", "00000000-0000-4000-8000-000000000301"); + const initial = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "delta-a", epoch: EPOCH, mutations: [upsert] }).expect(200); + const deletion = { + mutationId: "00000000-0000-4000-8000-000000000302", + kind: "delete", + profileId: PROFILE_ID, + occurredAt: "2026-08-15T00:01:00.000Z", + }; + const delta = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "delta-b", epoch: EPOCH, cursor: initial.body.cursor, mutations: [deletion] }).expect(200); + expect(delta.body.records).toEqual([expect.objectContaining({ kind: "tombstone", profileId: PROFILE_ID, revision: 2 })]); + const empty = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "delta-c", epoch: EPOCH, cursor: delta.body.cursor, mutations: [] }).expect(200); + expect(empty.body.records).toEqual([]); + }); + + test("replays a real desktop SQLite device-two outbox through Nest and PostgreSQL without cross-account path leakage", async () => { + const alice = await token("seam-alice"); + const bob = await token("seam-bob"); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${alice}`) + .send({ + deviceId: "device-one", + epoch: EPOCH, + mutations: [mutation("device-one-profile", "Device one", "00000000-0000-4000-8008-000000000001")], + }).expect(200); + + const manifest = fileURLToPath(new URL("../../desktop/src-tauri/Cargo.toml", import.meta.url)); + await execFileAsync("cargo", [ + "test", "--manifest-path", manifest, "--test", "hosted_sync_postgres", "--", "--ignored", "--nocapture", + ], { + env: { + ...process.env, + ROOTLINE_E2E_API_URL: apiUrl, + ROOTLINE_E2E_ALICE_TOKEN: alice, + ROOTLINE_E2E_BOB_TOKEN: bob, + }, + maxBuffer: 2 * 1024 * 1024, + timeout: 180_000, + }); + + const aliceRecords = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${alice}`) + .send({ deviceId: "verification-device", epoch: EPOCH, mutations: [] }).expect(200); + expect(aliceRecords.body.records).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "profile", profile: expect.objectContaining({ id: "device-two-offline-profile" }) }), + ])); + }, 180_000); + + test("rotates epoch on account deletion and rejects stale-device resurrection", async () => { + const auth = await token("reset-user"); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "reset-a", epoch: EPOCH, mutations: [mutation(PROFILE_ID, "Before delete", "00000000-0000-4000-8000-000000000401")] }).expect(200); + const deleted = await request(fixture.server).delete("/v1/account-data").set("Authorization", `Bearer ${auth}`) + .send({ epoch: EPOCH }).expect(200); + expect(deleted.body.epoch).not.toBe(EPOCH); + const stale = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "stale-device", epoch: EPOCH, mutations: [mutation(PROFILE_ID, "Must not return", "00000000-0000-4000-8000-000000000402")] }).expect(409); + expect(stale.body).toEqual(expect.objectContaining({ code: "RESET_REQUIRED", epoch: deleted.body.epoch })); + const current = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "fresh-device", epoch: deleted.body.epoch, mutations: [] }).expect(200); + expect(current.body.records).toEqual([]); + }); + + test("paginates bounded cursor deltas for a long-offline device", async () => { + const auth = await token("pagination-user"); + const firstBatch = Array.from({ length: 100 }, (_, index) => mutation( + `profile-page-${index}`, + `Page ${index}`, + `00000000-0000-4000-8003-${String(index).padStart(12, "0")}`, + )); + const first = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "writer", epoch: EPOCH, mutations: firstBatch }).expect(200); + const lastMutation = mutation("profile-page-last", "Last page", "00000000-0000-4000-8003-999999999999"); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "writer", epoch: EPOCH, cursor: first.body.cursor, mutations: [lastMutation] }).expect(200); + + const pageOne = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "offline-reader", epoch: EPOCH, mutations: [] }).expect(200); + expect(pageOne.body.records).toHaveLength(100); + expect(pageOne.body.hasMore).toBe(true); + const pageTwo = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "offline-reader", epoch: EPOCH, cursor: pageOne.body.cursor, mutations: [] }).expect(200); + expect(pageTwo.body.records).toHaveLength(1); + expect(pageTwo.body.hasMore).toBe(false); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "offline-reader", epoch: EPOCH, cursor: "not-a-cursor", mutations: [] }).expect(400); + }); + + test("enforces the shared profile limits at their exact boundaries", async () => { + const auth = await token("profile-limit-user"); + const exactCodePoints = (count: number) => "✈️".repeat(Math.floor(count / 2)) + (count % 2 ? "x" : ""); + const boundary = mutation(PROFILE_ID, exactCodePoints(80), "00000000-0000-4000-8000-000000000521"); + boundary.profile.sourcePath = exactCodePoints(4096); + boundary.profile.targetPath = exactCodePoints(4096); + boundary.profile.exclusions = Array.from({ length: 100 }, () => exactCodePoints(256)); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "profile-limits", epoch: EPOCH, mutations: [boundary] }).expect(200); + + const invalidProfiles = [ + { ...boundary.profile, name: exactCodePoints(81) }, + { ...boundary.profile, sourcePath: "" }, + { ...boundary.profile, targetPath: exactCodePoints(4097) }, + { ...boundary.profile, exclusions: Array.from({ length: 101 }, () => "x") }, + { ...boundary.profile, exclusions: [""] }, + { ...boundary.profile, exclusions: [exactCodePoints(257)] }, + ]; + for (const [index, profile] of invalidProfiles.entries()) { + const invalid = { + ...boundary, + mutationId: `00000000-0000-4000-8000-${String(522 + index).padStart(12, "0")}`, + profile, + }; + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "profile-limits", epoch: EPOCH, mutations: [invalid] }).expect(400); + } + }); + + test("enforces DTO, body, and per-user request limits", async () => { + const auth = await token("limits-user"); + const invalid = mutation(PROFILE_ID, "x".repeat(121), "00000000-0000-4000-8000-000000000501"); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "limits", epoch: EPOCH, mutations: [invalid] }).expect(400); + const tooMany = Array.from({ length: 101 }, (_, index) => mutation( + `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + "Profile", + `00000000-0000-4000-8001-${String(index).padStart(12, "0")}`, + )); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "limits", epoch: EPOCH, mutations: tooMany }).expect(400); + const errorLog = vi.spyOn(Logger.prototype, "error"); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .set("Content-Type", "application/json").send(JSON.stringify({ padding: "x".repeat(256 * 1024) })).expect(413); + expect(errorLog).not.toHaveBeenCalled(); + errorLog.mockRestore(); + + for (let index = 0; index < 58; index += 1) { + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "limits", epoch: EPOCH, mutations: [] }).expect(200); + } + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "limits", epoch: EPOCH, mutations: [] }).expect(429); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..0a787d3 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..d171c2b --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + fileParallelism: false, + hookTimeout: 30_000, + testTimeout: 30_000, + }, +}); diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..03ee2bc --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,13 @@ + + + + + + + Rootline by baole.space + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..d227e56 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rootline/desktop", + "version": "2.0.0", + "private": true, + "description": "Rootline by baole.space desktop application", + "license": "ISC", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite --host 127.0.0.1", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json --noEmit", + "tauri": "tauri" + }, + "dependencies": { + "@rootline/contracts": "workspace:*", + "@tauri-apps/api": "^2.8.0", + "@tauri-apps/plugin-deep-link": "^2.4.3", + "@tauri-apps/plugin-opener": "^2.5.0", + "@tauri-apps/plugin-stronghold": "^2.3.0", + "@tauri-apps/plugin-updater": "^2.10.0", + "oidc-client-ts": "^3.3.0", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.8.4", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.2", + "axe-core": "^4.10.3", + "jsdom": "^26.1.0", + "vite": "^7.1.2", + "vitest": "^3.2.4" + } +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..031e085 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.lock @@ -0,0 +1,7218 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[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-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[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-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-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.119", +] + +[[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.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq 0.4.2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "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 = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[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.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.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]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset 0.9.1", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "iota-crypto" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a38db844c910d78825e173c083f2ef416b69cb091bba8ac1055763c6db065b" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "autocfg", + "base64 0.21.7", + "blake2", + "chacha20poly1305", + "cipher", + "curve25519-dalek", + "digest", + "ed25519-zebra", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "hmac", + "iterator-sorted", + "k256", + "pbkdf2", + "rand 0.8.7", + "scrypt", + "serde", + "sha2", + "tiny-keccak", + "unicode-normalization", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "iota_stronghold" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0d301c7edbc31494d183b7d24c1bb51d3fb10fce2f3793df1baf45b6988e10" +dependencies = [ + "bincode", + "hkdf", + "iota-crypto", + "rust-argon2 1.0.0", + "serde", + "stronghold-derive", + "stronghold-utils", + "stronghold_engine", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "iterator-sorted" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d101775d2bc8f99f4ac18bf29b9ed70c0dd138b9a1e88d7b80179470cbbe8bd2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "4.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a" +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 = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libflate" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "libsodium-sys-stable" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b04bf6da2c98b727af37ab62cb505f4d751b975b034a9b9ad491d333b0564e" +dependencies = [ + "cc", + "libc", + "libflate", + "minisign-verify", + "pkg-config", + "tar", + "ureq", + "vcpkg", + "zip 8.6.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[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]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[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 = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[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 = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "rootline-desktop" +version = "2.0.0" +dependencies = [ + "keyring", + "libc", + "rand 0.9.5", + "reqwest 0.12.28", + "rfd", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-deep-link", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-stronghold", + "tauri-plugin-updater", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-argon2" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50162d19404029c1ceca6f6980fe40d45c8b369f6f44446fa14bb39573b5bb9" +dependencies = [ + "base64 0.13.1", + "blake2b_simd", + "constant_time_eq 0.1.5", + "crossbeam-utils", +] + +[[package]] +name = "rust-argon2" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d9848531d60c9cbbcf9d166c885316c24bc0e2a9d3eba0956bb6cbbd79bc6e8" +dependencies = [ + "base64 0.21.7", + "blake2b_simd", + "constant_time_eq 0.3.1", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "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", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_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 = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stronghold-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2835db23c4724c05a2f85b81c4681f4aa8ea158edc8a7f4ad791c916fb766c2e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "stronghold-runtime" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18db7cc51450cefdab5f4990e128dd02c98da6d2992b93ffef8992ac0d2f3ddf" +dependencies = [ + "dirs 4.0.0", + "iota-crypto", + "libc", + "libsodium-sys-stable", + "log", + "nix", + "rand 0.8.7", + "serde", + "thiserror 1.0.69", + "windows 0.36.1", + "zeroize", +] + +[[package]] +name = "stronghold-utils" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8300214898af5e153e7f66e49dbd1c6a21585f2d592d9f24f58b969792475ed6" +dependencies = [ + "rand 0.8.7", + "stronghold-derive", +] + +[[package]] +name = "stronghold_engine" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd7371c42e557dd71a7f860bb2ec6b6fdb32f97a97987ccc2435fdd1f3a8615" +dependencies = [ + "anyhow", + "dirs-next", + "hex", + "iota-crypto", + "once_cell", + "paste", + "serde", + "stronghold-runtime", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows 0.61.3", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.20", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-stronghold" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6307f47fcf548531743f6f12dbde075014bd8a22c5f8c14201e571de52cbd057" +dependencies = [ + "hex", + "iota-crypto", + "iota_stronghold", + "log", + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "rust-argon2 2.1.0", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "zeroize", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +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 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53b97a83176b369b0eb2fd8158d4ae215357d02df9d40c1e1bf1879c5482c80" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +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 1.0.4", + "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.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "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 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..3c5aa63 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "rootline-desktop" +version = "2.0.0" +description = "Rootline by baole.space desktop native boundary" +authors = ["baole.space"] +edition = "2021" +license = "ISC" + +[lib] +name = "rootline_desktop" +crate-type = ["lib", "cdylib", "staticlib"] + +[[bin]] +name = "rootline-desktop" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +keyring = "4.1" +rand = "0.9" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +rfd = "0.15" +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2", features = [] } +tauri-plugin-deep-link = "2.4" +tauri-plugin-opener = "2.5" +tauri-plugin-single-instance = { version = "2.4", features = ["deep-link"] } +tauri-plugin-stronghold = "2.3" +tauri-plugin-updater = "2.10" +thiserror = "2" +time = { version = "0.3", features = ["formatting"] } +tokio = { version = "1", features = ["sync"] } +url = "2" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tempfile = "3" + +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem"] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..cbae54b --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "rootline-main", + "description": "Rootline main window permissions", + "windows": ["main"], + "permissions": [ + "core:default", + "deep-link:default", + "opener:default", + "stronghold:default", + "stronghold:allow-remove-store-record", + "updater:default" + ] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..df929e6 Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..f43010c Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..bbbb3e2 Binary files /dev/null and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..a4f2e39 Binary files /dev/null and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..9660a24 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..d37389f Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..eb40a25 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..ee26b7c Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..a5798e7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..3f55069 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..d9ce951 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..6f1ef2a Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..1f57cff Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..2887397 Binary files /dev/null and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..d854f00 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..601f544 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..79db512 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/icons/rootline-mark.svg b/apps/desktop/src-tauri/icons/rootline-mark.svg new file mode 100644 index 0000000..05e1dd1 --- /dev/null +++ b/apps/desktop/src-tauri/icons/rootline-mark.svg @@ -0,0 +1,10 @@ + + Rootline + A cyan root line branches into a quiet folder tree on graphite. + + + + + + + diff --git a/apps/desktop/src-tauri/migrations/0001_offline_state.sql b/apps/desktop/src-tauri/migrations/0001_offline_state.sql new file mode 100644 index 0000000..31b4848 --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0001_offline_state.sql @@ -0,0 +1,37 @@ +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_path TEXT NOT NULL, + target_path TEXT NOT NULL, + exclusions_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE mutation_outbox ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + mutation_id TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + occurred_at TEXT NOT NULL +); + +CREATE TABLE sync_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + epoch TEXT NOT NULL, + cursor TEXT NOT NULL +); + +CREATE TABLE run_history ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + profile_id TEXT NOT NULL, + status TEXT NOT NULL, + created_count INTEGER NOT NULL, + result_json TEXT NOT NULL +); diff --git a/apps/desktop/src-tauri/migrations/0002_account_scoped_sync.sql b/apps/desktop/src-tauri/migrations/0002_account_scoped_sync.sql new file mode 100644 index 0000000..a8847ad --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0002_account_scoped_sync.sql @@ -0,0 +1 @@ +ALTER TABLE sync_state ADD COLUMN subject TEXT NOT NULL DEFAULT ''; diff --git a/apps/desktop/src-tauri/migrations/0003_sync_session_generation.sql b/apps/desktop/src-tauri/migrations/0003_sync_session_generation.sql new file mode 100644 index 0000000..152530f --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0003_sync_session_generation.sql @@ -0,0 +1 @@ +ALTER TABLE sync_state ADD COLUMN session_generation INTEGER NOT NULL DEFAULT 0; diff --git a/apps/desktop/src-tauri/migrations/0004_consented_epoch_adoption.sql b/apps/desktop/src-tauri/migrations/0004_consented_epoch_adoption.sql new file mode 100644 index 0000000..fcad241 --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0004_consented_epoch_adoption.sql @@ -0,0 +1,9 @@ +ALTER TABLE sync_state ADD COLUMN preserve_outbox_on_epoch_adopt INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mutation_outbox ADD COLUMN preserve_on_epoch_adopt INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mutation_outbox ADD COLUMN profile_id TEXT NOT NULL DEFAULT ''; +UPDATE mutation_outbox +SET profile_id = CASE + WHEN kind = 'upsert' THEN json_extract(payload, '$.id') + WHEN kind = 'delete' THEN json_extract(payload, '$.profileId') + ELSE '' +END; diff --git a/apps/desktop/src-tauri/migrations/0005_sync_lifecycle_generation.sql b/apps/desktop/src-tauri/migrations/0005_sync_lifecycle_generation.sql new file mode 100644 index 0000000..56294bf --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0005_sync_lifecycle_generation.sql @@ -0,0 +1,2 @@ +ALTER TABLE sync_state ADD COLUMN lifecycle_generation TEXT NOT NULL DEFAULT ''; +UPDATE sync_state SET lifecycle_generation=lower(hex(randomblob(16))); diff --git a/apps/desktop/src-tauri/migrations/0006_invalid_outbox_quarantine.sql b/apps/desktop/src-tauri/migrations/0006_invalid_outbox_quarantine.sql new file mode 100644 index 0000000..27f7336 --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0006_invalid_outbox_quarantine.sql @@ -0,0 +1,8 @@ +CREATE TABLE mutation_quarantine ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + mutation_id TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + profile_id TEXT NOT NULL, + reason TEXT NOT NULL, + quarantined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql b/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql new file mode 100644 index 0000000..0101400 --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql @@ -0,0 +1,27 @@ +ALTER TABLE mutation_quarantine ADD COLUMN provenance TEXT NOT NULL DEFAULT 'pre-login'; +ALTER TABLE mutation_quarantine ADD COLUMN subject TEXT NOT NULL DEFAULT ''; + +UPDATE mutation_quarantine +SET subject = '', provenance = 'pre-login'; + +CREATE TABLE profile_sync_policy ( + profile_id TEXT PRIMARY KEY, + policy TEXT NOT NULL CHECK (policy IN ('unclaimed', 'local-only', 'consented')), + subject TEXT NOT NULL DEFAULT '' +); + +INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) +SELECT profile_id, 'unclaimed', '' +FROM mutation_quarantine +WHERE provenance = 'pre-login' AND profile_id <> ''; + +INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) +SELECT id, 'unclaimed', '' +FROM profiles +WHERE COALESCE((SELECT subject FROM sync_state WHERE singleton = 1), '') = ''; + +INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) +SELECT profile_id, 'unclaimed', '' +FROM mutation_outbox +WHERE profile_id <> '' + AND COALESCE((SELECT subject FROM sync_state WHERE singleton = 1), '') = ''; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..1bec8cf --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,3610 @@ +use std::{ + collections::{HashMap, HashSet}, + fs, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +use keyring::Entry; +use rand::RngCore; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tauri::{AppHandle, Manager, State}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NativeErrorCode { + Cancelled, + InvalidPath, + PathOverlap, + SourceNotFound, + TargetNotFound, + UnreadablePath, + StalePlan, + AuthRequired, + AuthCallbackInvalid, + ResetRequired, + SyncAccountClaimRequired, + SyncStateChanged, + ValidationFailed, + Internal, +} + +#[derive(Debug, Serialize, thiserror::Error)] +#[error("{message}")] +pub struct NativeError { + pub code: NativeErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl NativeError { + fn new(code: NativeErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + fn at(code: NativeErrorCode, message: impl Into, path: &Path) -> Self { + Self { + code, + message: message.into(), + details: Some(json!({ "path": path })), + } + } +} + +impl From for NativeError { + fn from(error: rusqlite::Error) -> Self { + Self::new( + NativeErrorCode::Internal, + format!("Local database error: {error}"), + ) + } +} + +fn internal_error(error: impl std::fmt::Display) -> NativeError { + NativeError::new(NativeErrorCode::Internal, error.to_string()) +} + +pub fn random_vault_password() -> String { + let mut key = [0_u8; 32]; + rand::rng().fill_bytes(&mut key); + key.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub fn resolve_existing_vault_password( + stored: Result, + snapshot_exists: bool, +) -> Result, NativeError> { + match stored { + Ok(password) + if password.len() == 64 && password.bytes().all(|byte| byte.is_ascii_hexdigit()) => + { + Ok(Some(password)) + } + Ok(_) => Err(NativeError::new( + NativeErrorCode::Internal, + "The OS credential vault contains an invalid Rootline key.", + )), + Err(keyring::Error::NoEntry) if snapshot_exists => Err(NativeError::new( + NativeErrorCode::Internal, + "The Stronghold vault exists but its OS-protected key is missing.", + )), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(internal_error(error)), + } +} + +#[tauri::command] +fn auth_vault_password(app: AppHandle) -> Result { + let directory = app.path().app_data_dir().map_err(internal_error)?; + let snapshot = directory.join("rootline-auth.stronghold"); + let credential = + Entry::new("space.baole.rootline", "stronghold-vault-key").map_err(internal_error)?; + match resolve_existing_vault_password(credential.get_password(), snapshot.exists())? { + Some(password) => Ok(password), + None => { + let password = random_vault_password(); + credential.set_password(&password).map_err(internal_error)?; + Ok(password) + } + } +} + +#[derive(Clone, Default)] +pub struct CancellationToken(Arc); + +impl CancellationToken { + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + fn check(&self) -> Result<(), NativeError> { + if self.0.load(Ordering::Acquire) { + Err(NativeError::new( + NativeErrorCode::Cancelled, + "The operation was cancelled.", + )) + } else { + Ok(()) + } + } + + fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + +#[derive(Default)] +struct OperationRegistry(Mutex>); + +impl OperationRegistry { + fn begin(&self, id: &str) -> CancellationToken { + let token = CancellationToken::default(); + self.0 + .lock() + .expect("operation registry poisoned") + .insert(id.into(), token.clone()); + token + } + + fn finish(&self, id: &str) { + self.0 + .lock() + .expect("operation registry poisoned") + .remove(id); + } + + fn cancel(&self, id: &str) { + if let Some(token) = self.0.lock().expect("operation registry poisoned").get(id) { + token.cancel(); + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanRequest { + pub operation_id: String, + pub source_path: PathBuf, + pub target_path: PathBuf, + #[serde(default)] + pub exclusions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DirectoryStatus { + Created, + AlreadyExists, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DiffStatus { + Missing, + Exists, + Excluded, + Unreadable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiffEntry { + pub relative_path: String, + pub status: DiffStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileRootAvailability { + pub source_available: bool, + pub target_available: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryResult { + pub relative_path: String, + pub status: DirectoryStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanPlan { + pub operation_id: String, + pub source_root: PathBuf, + pub target_root: PathBuf, + pub source_fingerprint: String, + pub target_fingerprint: String, + pub target_case_sensitive: bool, + pub plan_fingerprint: String, + pub missing: Vec, + pub diff_entries: Vec, + pub skipped_links: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyResult { + pub run_id: String, + pub started_at: String, + pub finished_at: String, + pub directories: Vec, + pub cancelled: bool, +} + +#[derive(Debug)] +struct Snapshot { + entries: Vec, + fingerprint: String, + skipped_links: Vec, + excluded: Vec, + unreadable: Vec, +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|error| NativeError::new(NativeErrorCode::InvalidPath, error.to_string())) + } +} + +fn canonical_directory(path: &Path, role: &str) -> Result { + assert_no_link_ancestors(path)?; + let canonical = fs::canonicalize(path).map_err(|error| { + let code = if error.kind() == std::io::ErrorKind::NotFound { + if role == "source" { + NativeErrorCode::SourceNotFound + } else { + NativeErrorCode::TargetNotFound + } + } else { + NativeErrorCode::UnreadablePath + }; + NativeError::at(code, format!("The {role} folder cannot be read."), path) + })?; + if !canonical.is_dir() { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + format!("The {role} path is not a folder."), + path, + )); + } + Ok(canonical) +} + +fn assert_no_link_ancestors(path: &Path) -> Result<(), NativeError> { + let absolute = absolute(path)?; + let mut current = PathBuf::new(); + for component in absolute.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) + if is_link_or_junction(&metadata) && !is_allowed_platform_root_alias(¤t) => + { + return Err(NativeError { + code: NativeErrorCode::InvalidPath, + message: + "A synchronization root must not traverse a symbolic link or junction." + .into(), + details: Some(json!({ "path": absolute, "linkedAncestor": current })), + }); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + ¤t, + )); + } + } + } + Ok(()) +} + +fn profile_root_available(path: &Path) -> Result { + let absolute = absolute(path)?; + if assert_no_link_ancestors(&absolute).is_err() { + return Ok(false); + } + match fs::symlink_metadata(&absolute) { + Ok(metadata) => Ok(metadata.is_dir() && !is_link_or_junction(&metadata)), + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + || error.kind() == std::io::ErrorKind::PermissionDenied => + { + Ok(false) + } + Err(error) => Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + &absolute, + )), + } +} + +pub fn inspect_profile_roots( + source_path: &Path, + target_path: &Path, +) -> Result { + Ok(ProfileRootAvailability { + source_available: profile_root_available(source_path)?, + target_available: profile_root_available(target_path)?, + }) +} + +#[cfg(target_os = "macos")] +fn is_allowed_platform_root_alias(path: &Path) -> bool { + let expected = if path == Path::new("/var") { + Some(Path::new("/private/var")) + } else if path == Path::new("/tmp") { + Some(Path::new("/private/tmp")) + } else if path == Path::new("/etc") { + Some(Path::new("/private/etc")) + } else { + None + }; + expected.is_some_and(|expected| fs::canonicalize(path).is_ok_and(|actual| actual == expected)) +} + +#[cfg(not(target_os = "macos"))] +fn is_allowed_platform_root_alias(_path: &Path) -> bool { + false +} + +fn assert_not_link(path: &Path) -> Result<(), NativeError> { + let absolute = absolute(path)?; + match fs::symlink_metadata(&absolute) { + Ok(metadata) if is_link_or_junction(&metadata) => Err(NativeError::at( + NativeErrorCode::InvalidPath, + "A synchronization root must not be a symbolic link or junction.", + &absolute, + )), + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + &absolute, + )), + } +} + +#[cfg(not(windows))] +fn is_link_or_junction(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(windows)] +fn is_link_or_junction(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +fn assert_no_link_below(root: &Path, destination: &Path) -> Result<(), NativeError> { + let relative = destination.strip_prefix(root).map_err(|_| { + NativeError::at( + NativeErrorCode::InvalidPath, + "A destination escaped its synchronization root.", + destination, + ) + })?; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component); + assert_not_link(¤t)?; + } + Ok(()) +} + +fn path_key(path: &Path, case_sensitive: bool) -> String { + let value = path.to_string_lossy().replace('\\', "/"); + if case_sensitive { + value + } else { + value.to_lowercase() + } +} + +fn validate_relationship( + source: &Path, + target: &Path, + case_sensitive: bool, +) -> Result<(), NativeError> { + let source = path_key(source, case_sensitive); + let target = path_key(target, case_sensitive); + if source == target + || source.starts_with(&format!("{target}/")) + || target.starts_with(&format!("{source}/")) + { + return Err(NativeError::new( + NativeErrorCode::PathOverlap, + "Source and target roots must not overlap.", + )); + } + Ok(()) +} + +fn nearest_existing_ancestor(path: &Path) -> Result { + let mut current = absolute(path)?; + loop { + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() => return Ok(current), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + ¤t, + )); + } + } + if !current.pop() { + return Err(NativeError::at( + NativeErrorCode::InvalidPath, + "The path has no existing directory ancestor.", + path, + )); + } + } +} + +#[cfg(target_os = "macos")] +fn case_sensitive_from_os(directory: &Path) -> Result { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + #[repr(C)] + struct VolumeCapabilitiesBuffer { + length: u32, + capabilities: [u32; 4], + valid: [u32; 4], + } + + let path = CString::new(directory.as_os_str().as_bytes()).map_err(|_| { + NativeError::at( + NativeErrorCode::InvalidPath, + "The target path contains a null byte.", + directory, + ) + })?; + let mut attributes = libc::attrlist { + bitmapcount: libc::ATTR_BIT_MAP_COUNT, + reserved: 0, + commonattr: 0, + volattr: libc::ATTR_VOL_CAPABILITIES, + dirattr: 0, + fileattr: 0, + forkattr: 0, + }; + let mut buffer = VolumeCapabilitiesBuffer { + length: 0, + capabilities: [0; 4], + valid: [0; 4], + }; + // SAFETY: `path`, `attributes`, and `buffer` remain valid for this synchronous + // call, and the buffer exactly matches the requested fixed-size volume attribute. + let result = unsafe { + libc::getattrlist( + path.as_ptr(), + (&mut attributes as *mut libc::attrlist).cast(), + (&mut buffer as *mut VolumeCapabilitiesBuffer).cast(), + std::mem::size_of::(), + 0, + ) + }; + if result != 0 { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + std::io::Error::last_os_error().to_string(), + directory, + )); + } + let capability = libc::VOL_CAP_FMT_CASE_SENSITIVE; + Ok(buffer.valid[0] & capability != 0 && buffer.capabilities[0] & capability != 0) +} + +#[cfg(windows)] +fn case_sensitive_from_os(directory: &Path) -> Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + Storage::FileSystem::{ + CreateFileW, FileCaseSensitiveInfo, GetFileInformationByHandleEx, + FILE_CASE_SENSITIVE_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }, + }; + + let wide = directory + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: the UTF-16 path is null-terminated and all other arguments are constants/null. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + std::io::Error::last_os_error().to_string(), + directory, + )); + } + let mut information = FILE_CASE_SENSITIVE_INFO::default(); + // SAFETY: `handle` is live and `information` is the exact structure requested. + let result = unsafe { + GetFileInformationByHandleEx( + handle, + FileCaseSensitiveInfo, + (&mut information as *mut FILE_CASE_SENSITIVE_INFO).cast(), + std::mem::size_of::() as u32, + ) + }; + // SAFETY: the handle came from CreateFileW and is closed exactly once here. + unsafe { CloseHandle(handle) }; + if result == 0 { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + std::io::Error::last_os_error().to_string(), + directory, + )); + } + Ok(information.Flags & 1 != 0) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn case_sensitive_from_os(_directory: &Path) -> Result { + Ok(true) +} + +pub fn detect_case_sensitive(directory: &Path) -> Result { + case_sensitive_from_os(&nearest_existing_ancestor(directory)?) +} + +fn normalize_relative(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +fn glob_segment_matches(value: &str, pattern: &str) -> bool { + let value: Vec<_> = value.chars().collect(); + let pattern: Vec<_> = pattern.chars().collect(); + fn visit( + value: &[char], + pattern: &[char], + value_index: usize, + pattern_index: usize, + memo: &mut HashMap<(usize, usize), bool>, + ) -> bool { + if let Some(result) = memo.get(&(value_index, pattern_index)) { + return *result; + } + let result = if pattern_index == pattern.len() { + value_index == value.len() + } else if pattern[pattern_index] == '*' && pattern.get(pattern_index + 1) == Some(&'*') { + let after_globstar = pattern_index + 2; + let skips_empty_segment = pattern.get(after_globstar) == Some(&'/') + && visit(value, pattern, value_index, after_globstar + 1, memo); + skips_empty_segment + || visit(value, pattern, value_index, after_globstar, memo) + || (value_index < value.len() + && visit(value, pattern, value_index + 1, pattern_index, memo)) + } else if pattern[pattern_index] == '*' { + visit(value, pattern, value_index, pattern_index + 1, memo) + || (value_index < value.len() + && value[value_index] != '/' + && visit(value, pattern, value_index + 1, pattern_index, memo)) + } else if pattern[pattern_index] == '?' { + value_index < value.len() + && value[value_index] != '/' + && visit(value, pattern, value_index + 1, pattern_index + 1, memo) + } else { + value_index < value.len() + && value[value_index] == pattern[pattern_index] + && visit(value, pattern, value_index + 1, pattern_index + 1, memo) + }; + memo.insert((value_index, pattern_index), result); + result + } + visit(&value, &pattern, 0, 0, &mut HashMap::new()) +} + +fn excluded(relative: &str, patterns: &[String]) -> bool { + patterns.iter().any(|pattern| { + let normalized = pattern.replace('\\', "/"); + if normalized.contains('/') { + glob_segment_matches(relative, &normalized) + } else { + relative + .split('/') + .any(|segment| glob_segment_matches(segment, &normalized)) + } + }) +} + +fn fingerprint(values: &[String]) -> String { + let mut hash = 0x811c9dc5_u32; + for value in values { + for byte in value.as_bytes() { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(0x01000193); + } + hash ^= 10; + hash = hash.wrapping_mul(0x01000193); + } + format!("fnv1a-{hash:08x}") +} + +fn scan_root( + root: &Path, + exclusions: &[String], + token: &CancellationToken, +) -> Result { + let mut entries = Vec::new(); + let mut skipped_links = Vec::new(); + let mut excluded_entries = Vec::new(); + let mut unreadable = Vec::new(); + let mut pending = vec![(root.to_path_buf(), String::new())]; + while let Some((current, current_relative)) = pending.pop() { + token.check()?; + if current.join(".ignore").exists() { + continue; + } + let mut children = match fs::read_dir(¤t) + .and_then(|entries| entries.collect::, _>>()) + { + Ok(children) => children, + Err(_error) if !current_relative.is_empty() => { + unreadable.push(current_relative); + continue; + } + Err(error) => { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + ¤t, + )); + } + }; + children.sort_by_key(|entry| entry.file_name()); + for child in children.into_iter().rev() { + token.check()?; + let path = child.path(); + let relative = + normalize_relative(path.strip_prefix(root).expect("entry is below root")); + if excluded(&relative, exclusions) { + excluded_entries.push(relative); + continue; + } + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(_) => { + unreadable.push(relative); + continue; + } + }; + if is_link_or_junction(&metadata) { + skipped_links.push(relative); + } else if metadata.is_dir() { + entries.push(relative.clone()); + pending.push((path, relative)); + } + } + } + entries.sort(); + skipped_links.sort(); + excluded_entries.sort(); + unreadable.sort(); + let mut fingerprint_values = entries.clone(); + fingerprint_values.extend( + excluded_entries + .iter() + .map(|path| format!("excluded:{path}")), + ); + fingerprint_values.extend(unreadable.iter().map(|path| format!("unreadable:{path}"))); + fingerprint_values.extend(skipped_links.iter().map(|path| format!("linked:{path}"))); + let fingerprint = fingerprint(&fingerprint_values); + Ok(Snapshot { + entries, + fingerprint, + skipped_links, + excluded: excluded_entries, + unreadable, + }) +} + +fn contains_path_or_ancestor(paths: &HashSet, relative: &str) -> bool { + let mut current = relative; + loop { + if paths.contains(current) { + return true; + } + let Some(separator) = current.rfind('/') else { + return false; + }; + current = ¤t[..separator]; + } +} + +pub fn scan_plan( + request: &ScanRequest, + token: &CancellationToken, +) -> Result { + token.check()?; + let source = canonical_directory(&request.source_path, "source")?; + let target = canonical_directory(&request.target_path, "target")?; + let target_case_sensitive = detect_case_sensitive(&target)?; + validate_relationship(&source, &target, target_case_sensitive)?; + let source_snapshot = scan_root(&source, &request.exclusions, token)?; + let target_snapshot = scan_root(&target, &request.exclusions, token)?; + let comparable = |value: &str| { + if target_case_sensitive { + value.to_owned() + } else { + value.to_lowercase() + } + }; + let target_entries: HashSet<_> = target_snapshot + .entries + .iter() + .map(|entry| comparable(entry)) + .collect(); + let source_unreadable: HashSet<_> = source_snapshot.unreadable.iter().cloned().collect(); + let target_unreadable: HashSet<_> = target_snapshot + .unreadable + .iter() + .map(|entry| comparable(entry)) + .collect(); + let mut statuses = HashMap::new(); + for entry in &source_snapshot.entries { + let comparable_entry = comparable(entry); + let status = if contains_path_or_ancestor(&source_unreadable, entry) + || contains_path_or_ancestor(&target_unreadable, &comparable_entry) + { + DiffStatus::Unreadable + } else if target_entries.contains(&comparable_entry) { + DiffStatus::Exists + } else { + DiffStatus::Missing + }; + statuses.insert(entry.clone(), status); + } + for entry in &source_snapshot.excluded { + statuses.insert(entry.clone(), DiffStatus::Excluded); + } + for entry in &source_snapshot.unreadable { + statuses.insert(entry.clone(), DiffStatus::Unreadable); + } + let mut diff_entries: Vec<_> = statuses + .into_iter() + .map(|(relative_path, status)| DiffEntry { + relative_path, + status, + }) + .collect(); + diff_entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let missing: Vec<_> = diff_entries + .iter() + .filter(|entry| entry.status == DiffStatus::Missing) + .map(|entry| entry.relative_path.clone()) + .collect(); + let mut plan_values = vec![ + source.to_string_lossy().into_owned(), + target.to_string_lossy().into_owned(), + source_snapshot.fingerprint.clone(), + target_snapshot.fingerprint.clone(), + if target_case_sensitive { + "case-sensitive".into() + } else { + "case-insensitive".into() + }, + ]; + plan_values.extend( + diff_entries + .iter() + .map(|entry| format!("{:?}:{}", entry.status, entry.relative_path)), + ); + Ok(ScanPlan { + operation_id: request.operation_id.clone(), + source_root: source, + target_root: target, + source_fingerprint: source_snapshot.fingerprint, + target_fingerprint: target_snapshot.fingerprint, + target_case_sensitive, + plan_fingerprint: fingerprint(&plan_values), + missing, + diff_entries, + skipped_links: source_snapshot.skipped_links, + }) +} + +fn selected_with_parents(plan: &ScanPlan, requested: &[String]) -> Vec { + let missing: HashSet<_> = plan.missing.iter().cloned().collect(); + let mut selected = HashSet::new(); + for path in requested { + if !missing.contains(path) { + continue; + } + let parts: Vec<_> = path.split('/').collect(); + for depth in 1..=parts.len() { + let parent = parts[..depth].join("/"); + if missing.contains(&parent) { + selected.insert(parent); + } + } + } + let mut selected: Vec<_> = selected.into_iter().collect(); + selected.sort_by(|left, right| { + left.matches('/') + .count() + .cmp(&right.matches('/').count()) + .then(left.cmp(right)) + }); + selected +} + +pub fn apply_plan( + request: &ScanRequest, + plan: &ScanPlan, + selected: &[String], + token: &CancellationToken, +) -> Result { + apply_plan_with_observer(request, plan, selected, token, |_| {}) +} + +fn apply_plan_with_observer( + request: &ScanRequest, + plan: &ScanPlan, + selected: &[String], + token: &CancellationToken, + mut after_directory: impl FnMut(&DirectoryResult), +) -> Result { + let started_at = timestamp(); + let source = canonical_directory(&request.source_path, "source")?; + let target = canonical_directory(&request.target_path, "target")?; + if source != plan.source_root || target != plan.target_root { + return Err(NativeError::new( + NativeErrorCode::StalePlan, + "The selected roots differ from the reviewed plan.", + )); + } + let current = scan_plan(request, token)?; + if current.source_fingerprint != plan.source_fingerprint + || current.target_fingerprint != plan.target_fingerprint + || current.plan_fingerprint != plan.plan_fingerprint + || current.missing != plan.missing + || current.diff_entries != plan.diff_entries + { + return Err(NativeError::new( + NativeErrorCode::StalePlan, + "The folders changed after review. Scan again before applying.", + )); + } + let mut directories = Vec::new(); + for relative in selected_with_parents(plan, selected) { + if token.is_cancelled() { + break; + } + if relative + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(NativeError::new( + NativeErrorCode::InvalidPath, + "A plan contains an invalid relative path.", + )); + } + let destination = relative + .split('/') + .fold(target.clone(), |path, part| path.join(part)); + assert_no_link_below(&target, &destination)?; + let entry = create_directory_result(relative, &destination); + after_directory(&entry); + directories.push(entry); + } + Ok(ApplyResult { + run_id: Uuid::new_v4().to_string(), + started_at, + finished_at: timestamp(), + directories, + cancelled: token.is_cancelled(), + }) +} + +fn create_directory_result(relative_path: String, destination: &Path) -> DirectoryResult { + match fs::create_dir(destination) { + Ok(()) => DirectoryResult { + relative_path, + status: DirectoryStatus::Created, + error: None, + }, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && destination.is_dir() => { + DirectoryResult { + relative_path, + status: DirectoryStatus::AlreadyExists, + error: None, + } + } + Err(error) => DirectoryResult { + relative_path, + status: DirectoryStatus::Failed, + error: Some(error.to_string()), + }, + } +} + +fn timestamp() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .to_string() + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + pub id: String, + pub name: String, + pub source_path: String, + pub target_path: String, + pub exclusions: Vec, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +struct CharacterLimits { + min: usize, + max: usize, +} + +#[derive(Debug, Deserialize)] +struct ExclusionLimits { + max: usize, + pattern: CharacterLimits, +} + +#[derive(Debug, Deserialize)] +struct ProfileLimits { + #[serde(rename = "lengthUnit")] + length_unit: String, + name: CharacterLimits, + path: CharacterLimits, + exclusions: ExclusionLimits, +} + +fn profile_limits() -> &'static ProfileLimits { + static LIMITS: OnceLock = OnceLock::new(); + LIMITS.get_or_init(|| { + serde_json::from_str(include_str!( + "../../../../packages/contracts/src/profile-limits.json" + )) + .expect("shared profile limits must be valid JSON") + }) +} + +fn profile_validation_error(field: &str, min: Option, max: usize) -> NativeError { + NativeError { + code: NativeErrorCode::ValidationFailed, + message: "The profile exceeds Rootline's hosted profile limits.".into(), + details: Some(json!({ "field": field, "min": min, "max": max })), + } +} + +fn validate_profile(profile: &Profile) -> Result<(), NativeError> { + let id_length = profile.id.chars().count(); + if !(1..=128).contains(&id_length) { + return Err(profile_validation_error("id", Some(1), 128)); + } + let limits = profile_limits(); + assert_eq!(limits.length_unit, "unicode-code-points"); + for (field, value, limit) in [ + ("name", profile.name.as_str(), &limits.name), + ("sourcePath", profile.source_path.as_str(), &limits.path), + ("targetPath", profile.target_path.as_str(), &limits.path), + ] { + let length = value.chars().count(); + if length < limit.min || length > limit.max { + return Err(profile_validation_error(field, Some(limit.min), limit.max)); + } + } + if profile.exclusions.len() > limits.exclusions.max { + return Err(profile_validation_error( + "exclusions", + None, + limits.exclusions.max, + )); + } + for (index, pattern) in profile.exclusions.iter().enumerate() { + let length = pattern.chars().count(); + let limit = &limits.exclusions.pattern; + if length < limit.min || length > limit.max { + return Err(profile_validation_error( + &format!("exclusions[{index}]"), + Some(limit.min), + limit.max, + )); + } + } + Ok(()) +} + +fn outbox_validation_error( + mutation_id: &str, + kind: &str, + payload: &str, + occurred_at: &str, +) -> Option { + if Uuid::parse_str(mutation_id).is_err() { + return Some("mutationId is not a UUID".into()); + } + if OffsetDateTime::parse(occurred_at, &Rfc3339).is_err() { + return Some("occurredAt is not an RFC 3339 timestamp".into()); + } + match kind { + "upsert" => { + let profile: Profile = match serde_json::from_str(payload) { + Ok(profile) => profile, + Err(_) => return Some("profile payload is invalid JSON".into()), + }; + if OffsetDateTime::parse(&profile.created_at, &Rfc3339).is_err() + || OffsetDateTime::parse(&profile.updated_at, &Rfc3339).is_err() + { + return Some("profile timestamps are invalid".into()); + } + validate_profile(&profile).err().map(|error| { + let field = error + .details + .as_ref() + .and_then(|details| details.get("field")) + .and_then(serde_json::Value::as_str) + .unwrap_or("profile"); + format!("profile {field} exceeds the hosted limit") + }) + } + "delete" => { + let profile_id = serde_json::from_str::(payload) + .ok() + .and_then(|value| { + value + .get("profileId") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + }); + match profile_id { + Some(profile_id) if (1..=128).contains(&profile_id.chars().count()) => None, + _ => Some("delete profileId is invalid".into()), + } + } + _ => Some("mutation kind is invalid".into()), + } +} + +fn quarantine_invalid_outbox(connection: &mut Connection) -> Result { + let owner = connection + .query_row( + "SELECT subject FROM sync_state WHERE singleton=1", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_default(); + let candidates = { + let mut statement = connection.prepare( + "SELECT sequence, mutation_id, kind, payload, occurred_at, profile_id, + preserve_on_epoch_adopt + FROM mutation_outbox ORDER BY sequence ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i64>(6)?, + )) + })?; + rows.collect::, _>>()? + }; + let transaction = connection.transaction()?; + let mut quarantined = 0; + for (sequence, mutation_id, kind, payload, occurred_at, profile_id, preserve) in candidates { + let Some(reason) = outbox_validation_error(&mutation_id, &kind, &payload, &occurred_at) + else { + continue; + }; + transaction.execute( + "INSERT INTO mutation_quarantine( + mutation_id, kind, profile_id, reason, provenance, subject + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(mutation_id) DO UPDATE SET kind=excluded.kind, + profile_id=excluded.profile_id, reason=excluded.reason, + provenance=excluded.provenance, subject=excluded.subject, + quarantined_at=CURRENT_TIMESTAMP", + params![ + mutation_id, + kind, + profile_id, + reason, + if owner.is_empty() { + "pre-login" + } else { + "account-bound" + }, + owner + ], + )?; + if !profile_id.is_empty() && owner.is_empty() { + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + VALUES (?1, 'unclaimed', '') + ON CONFLICT(profile_id) DO NOTHING", + [&profile_id], + )?; + } else if !profile_id.is_empty() && preserve == 1 { + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + VALUES (?1, 'consented', ?2) + ON CONFLICT(profile_id) DO UPDATE SET policy='consented', subject=excluded.subject", + params![profile_id, owner], + )?; + } + transaction.execute( + "DELETE FROM mutation_outbox WHERE sequence = ?1", + [sequence], + )?; + quarantined += 1; + } + if quarantined > 0 { + transaction.execute( + "UPDATE sync_state SET session_generation=session_generation + 1 WHERE singleton=1", + [], + )?; + } + transaction.commit()?; + Ok(quarantined) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutboxMutation { + pub mutation_id: String, + pub kind: String, + pub payload: String, + pub occurred_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuarantinedMutation { + pub mutation_id: String, + pub profile_id: String, + pub reason: String, + pub provenance: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunRecord { + pub id: String, + pub profile_id: String, + pub status: String, + pub created_count: i64, + pub result_json: String, +} + +pub struct Database(Mutex); + +const MIGRATIONS: &[(i64, &str)] = &[ + (1, include_str!("../migrations/0001_offline_state.sql")), + ( + 2, + include_str!("../migrations/0002_account_scoped_sync.sql"), + ), + ( + 3, + include_str!("../migrations/0003_sync_session_generation.sql"), + ), + ( + 4, + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + ), + ( + 5, + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + ), + ( + 6, + include_str!("../migrations/0006_invalid_outbox_quarantine.sql"), + ), + ( + 7, + include_str!("../migrations/0007_profile_sync_provenance.sql"), + ), +]; +const HOSTED_SYNC_MUTATION_LIMIT: usize = 100; +const HOSTED_SYNC_BODY_LIMIT: usize = 256 * 1024; + +impl Database { + pub fn open(path: impl AsRef) -> Result { + let mut connection = Connection::open(path)?; + connection.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY);", + )?; + let current_version: i64 = connection.query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_migrations", + [], + |row| row.get(0), + )?; + for (version, sql) in MIGRATIONS + .iter() + .filter(|(version, _)| *version > current_version) + { + let transaction = connection.transaction()?; + transaction.execute_batch(sql)?; + transaction.execute( + "INSERT INTO schema_migrations(version) VALUES (?1)", + [version], + )?; + transaction.commit()?; + } + quarantine_invalid_outbox(&mut connection)?; + Ok(Self(Mutex::new(connection))) + } + + fn connection(&self) -> std::sync::MutexGuard<'_, Connection> { + self.0.lock().expect("database mutex poisoned") + } + + pub fn device_id(&self) -> Result { + let connection = self.connection(); + if let Some(id) = connection + .query_row( + "SELECT value FROM settings WHERE key = 'device_id'", + [], + |row| row.get(0), + ) + .optional()? + { + return Ok(id); + } + let id = Uuid::new_v4().to_string(); + connection.execute( + "INSERT INTO settings(key, value) VALUES ('device_id', ?1)", + [&id], + )?; + Ok(id) + } + + pub fn save_profile(&self, profile: &Profile) -> Result<(), NativeError> { + validate_profile(profile)?; + let payload = serde_json::to_string(profile) + .map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; + let mut connection = self.connection(); + let transaction = connection.transaction()?; + let owner = transaction + .query_row( + "SELECT subject FROM sync_state WHERE singleton=1", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_default(); + if owner.is_empty() { + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + VALUES (?1, 'unclaimed', '') + ON CONFLICT(profile_id) DO UPDATE SET policy='unclaimed', subject=''", + [&profile.id], + )?; + } + let policy = transaction + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let policy_matches_owner = policy + .as_ref() + .is_some_and(|(_, subject)| subject == &owner); + let local_only = policy_matches_owner + && policy + .as_ref() + .is_some_and(|(policy, _)| policy == "local-only"); + let policy_owner_mismatch = !owner.is_empty() + && policy + .as_ref() + .is_some_and(|(_, subject)| subject != &owner); + let preserve = policy_matches_owner + && policy + .as_ref() + .is_some_and(|(policy, _)| policy == "consented") + || transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox + WHERE profile_id=?1 AND preserve_on_epoch_adopt=1 + )", + [&profile.id], + |row| row.get::<_, i64>(0), + )? == 1; + transaction.execute( + "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, source_path=excluded.source_path, + target_path=excluded.target_path, exclusions_json=excluded.exclusions_json, updated_at=excluded.updated_at", + params![profile.id, profile.name, profile.source_path, profile.target_path, + serde_json::to_string(&profile.exclusions).map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?, + profile.created_at, profile.updated_at], + )?; + transaction.execute( + "DELETE FROM mutation_quarantine WHERE profile_id = ?1", + [&profile.id], + )?; + if local_only || policy_owner_mismatch { + transaction.execute( + "DELETE FROM mutation_outbox WHERE profile_id=?1", + [&profile.id], + )?; + } else { + transaction.execute( + "INSERT INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt + ) VALUES (?1, 'upsert', ?2, ?3, ?4, ?5)", + params![ + Uuid::new_v4().to_string(), + payload, + profile.updated_at, + profile.id, + i64::from(preserve) + ], + )?; + } + transaction.execute( + "UPDATE sync_state SET session_generation=session_generation + 1 WHERE singleton=1", + [], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn list_profiles(&self) -> Result, NativeError> { + let connection = self.connection(); + let mut statement = connection.prepare( + "SELECT id, name, source_path, target_path, exclusions_json, created_at, updated_at + FROM profiles ORDER BY updated_at DESC, id ASC", + )?; + let rows = statement.query_map([], |row| { + let exclusions: String = row.get(4)?; + Ok(Profile { + id: row.get(0)?, + name: row.get(1)?, + source_path: row.get(2)?, + target_path: row.get(3)?, + exclusions: serde_json::from_str(&exclusions).unwrap_or_default(), + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + })?; + Ok(rows.collect::, _>>()?) + } + + pub fn delete_profile(&self, id: &str) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + let owner = transaction + .query_row( + "SELECT subject FROM sync_state WHERE singleton=1", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_default(); + let policy = transaction + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let local_only = policy + .as_ref() + .is_some_and(|(policy, subject)| policy == "local-only" && subject == &owner); + let preserve = policy + .as_ref() + .is_some_and(|(policy, subject)| policy == "consented" && subject == &owner) + || transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox + WHERE profile_id=?1 AND preserve_on_epoch_adopt=1 + )", + [id], + |row| row.get::<_, i64>(0), + )? == 1; + transaction.execute("DELETE FROM profiles WHERE id = ?1", [id])?; + transaction.execute( + "DELETE FROM mutation_quarantine WHERE profile_id = ?1", + [id], + )?; + if local_only { + transaction.execute("DELETE FROM mutation_outbox WHERE profile_id=?1", [id])?; + transaction.execute("DELETE FROM profile_sync_policy WHERE profile_id=?1", [id])?; + } else { + transaction.execute( + "INSERT INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt + ) VALUES (?1, 'delete', ?2, ?3, ?4, ?5)", + params![ + Uuid::new_v4().to_string(), + json!({ "profileId": id }).to_string(), + timestamp(), + id, + i64::from(preserve) + ], + )?; + } + transaction.execute( + "UPDATE sync_state SET session_generation=session_generation + 1 WHERE singleton=1", + [], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn enqueue_mutation( + &self, + id: &str, + kind: &str, + payload: &str, + occurred_at: &str, + ) -> Result<(), NativeError> { + let parsed: serde_json::Value = serde_json::from_str(payload).map_err(internal_error)?; + let profile_id = parsed + .get(if kind == "upsert" { "id" } else { "profileId" }) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "A local outbox mutation is invalid.", + ) + })?; + self.connection().execute( + "INSERT OR IGNORE INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, kind, payload, occurred_at, profile_id], + )?; + Ok(()) + } + + pub fn pending_outbox(&self) -> Result, NativeError> { + let connection = self.connection(); + let mut statement = connection.prepare( + "SELECT mutation_id, kind, payload, occurred_at FROM mutation_outbox ORDER BY sequence ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(OutboxMutation { + mutation_id: row.get(0)?, + kind: row.get(1)?, + payload: row.get(2)?, + occurred_at: row.get(3)?, + }) + })?; + Ok(rows.collect::, _>>()?) + } + + pub fn quarantined_mutations(&self) -> Result, NativeError> { + let connection = self.connection(); + let mut statement = connection.prepare( + "SELECT mutation_id, profile_id, reason, provenance + FROM mutation_quarantine ORDER BY sequence ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(QuarantinedMutation { + mutation_id: row.get(0)?, + profile_id: row.get(1)?, + reason: row.get(2)?, + provenance: row.get(3)?, + }) + })?; + Ok(rows.collect::, _>>()?) + } + + pub fn acknowledge_mutations(&self, ids: &[String]) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + for id in ids { + transaction.execute("DELETE FROM mutation_outbox WHERE mutation_id = ?1", [id])?; + } + transaction.commit()?; + Ok(()) + } + + pub fn set_sync_cursor(&self, epoch: &str, cursor: &str) -> Result<(), NativeError> { + let lifecycle_generation = Uuid::new_v4().to_string(); + self.connection().execute( + "INSERT INTO sync_state(singleton, epoch, cursor, subject, lifecycle_generation) + VALUES (1, ?1, ?2, '', ?3) + ON CONFLICT(singleton) DO UPDATE SET epoch=excluded.epoch, cursor=excluded.cursor, + lifecycle_generation=excluded.lifecycle_generation", + params![epoch, cursor, lifecycle_generation], + )?; + Ok(()) + } + + pub fn sync_cursor(&self) -> Result, NativeError> { + Ok(self + .connection() + .query_row( + "SELECT epoch, cursor FROM sync_state WHERE singleton = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?) + } + + fn sync_binding(&self) -> Result, NativeError> { + Ok(self + .connection() + .query_row( + "SELECT subject, epoch, cursor, session_generation FROM sync_state WHERE singleton = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?) + } + + pub fn clear_local_synced_data(&self) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + transaction.execute("DELETE FROM profiles", [])?; + transaction.execute("DELETE FROM mutation_outbox", [])?; + transaction.execute("DELETE FROM mutation_quarantine", [])?; + transaction.execute("DELETE FROM profile_sync_policy", [])?; + transaction.execute("DELETE FROM sync_state", [])?; + transaction.commit()?; + Ok(()) + } + + pub fn disconnect_hosted_account( + &self, + remove_local_profiles: bool, + ) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + let lifecycle_generation = Uuid::new_v4().to_string(); + transaction.execute("DELETE FROM mutation_outbox", [])?; + transaction.execute("DELETE FROM profile_sync_policy", [])?; + if !remove_local_profiles { + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + SELECT id, 'unclaimed', '' FROM profiles", + [], + )?; + transaction.execute( + "UPDATE mutation_quarantine SET provenance='pre-login', subject=''", + [], + )?; + } + transaction.execute( + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, + preserve_outbox_on_epoch_adopt, lifecycle_generation) + VALUES (1, '', '', '', 1, 0, ?1) + ON CONFLICT(singleton) DO UPDATE SET subject='', epoch='', cursor='', + session_generation=sync_state.session_generation + 1, preserve_outbox_on_epoch_adopt=0, + lifecycle_generation=excluded.lifecycle_generation", + [lifecycle_generation], + )?; + if remove_local_profiles { + transaction.execute("DELETE FROM profiles", [])?; + transaction.execute("DELETE FROM mutation_quarantine", [])?; + } + transaction.commit()?; + Ok(()) + } + + pub fn claim_hosted_account( + &self, + subject: &str, + upload_existing: bool, + ) -> Result<(), NativeError> { + if subject.is_empty() { + return Err(NativeError::new( + NativeErrorCode::AuthRequired, + "An account subject is required.", + )); + } + let mut connection = self.connection(); + let transaction = connection.transaction()?; + let owner: Option = transaction + .query_row( + "SELECT subject FROM sync_state WHERE singleton = 1", + [], + |row| row.get(0), + ) + .optional()?; + if owner + .as_deref() + .is_some_and(|value| !value.is_empty() && value != subject) + { + return Err(NativeError::new( + NativeErrorCode::AuthRequired, + "Local hosted-sync state belongs to another account.", + )); + } + let same_owner = owner.as_deref() == Some(subject); + let unresolved_claim: i64 = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' + UNION ALL + SELECT 1 + FROM mutation_quarantine quarantine + LEFT JOIN profile_sync_policy policy ON policy.profile_id=quarantine.profile_id + WHERE quarantine.provenance='pre-login' + AND (policy.profile_id IS NULL OR policy.policy='unclaimed') + )", + [], + |row| row.get(0), + )?; + if same_owner && !upload_existing && unresolved_claim == 0 { + transaction.commit()?; + return Ok(()); + } + if !upload_existing { + if same_owner { + transaction.execute( + "INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'unclaimed', '' FROM mutation_quarantine + WHERE provenance='pre-login' AND profile_id<>''", + [], + )?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='local-only', subject=?1 + WHERE policy='unclaimed'", + [subject], + )?; + transaction.execute( + "DELETE FROM mutation_outbox + WHERE profile_id IN ( + SELECT profile_id FROM profile_sync_policy + WHERE policy='local-only' AND subject=?1 + )", + [subject], + )?; + } else { + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT id, 'local-only', ?1 FROM profiles", + [subject], + )?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'local-only', ?1 FROM mutation_outbox + WHERE profile_id<>''", + [subject], + )?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'local-only', ?1 FROM mutation_quarantine + WHERE profile_id<>''", + [subject], + )?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='local-only', subject=?1 + WHERE policy='unclaimed'", + [subject], + )?; + transaction.execute("DELETE FROM mutation_outbox", [])?; + } + transaction.execute( + "UPDATE mutation_quarantine SET subject=?1 + WHERE provenance='pre-login' AND profile_id IN ( + SELECT profile_id FROM profile_sync_policy + WHERE policy='local-only' AND subject=?1 + )", + [subject], + )?; + } else { + if same_owner { + transaction.execute( + "INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'unclaimed', '' FROM mutation_quarantine + WHERE provenance='pre-login' AND profile_id<>''", + [], + )?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='consented', subject=?1 + WHERE policy='unclaimed' OR (policy='local-only' AND subject=?1)", + [subject], + )?; + transaction.execute( + "UPDATE mutation_outbox SET preserve_on_epoch_adopt=1 + WHERE profile_id IN ( + SELECT profile_id FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 + )", + [subject], + )?; + } else { + transaction.execute("UPDATE mutation_outbox SET preserve_on_epoch_adopt=1", [])?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT id, 'consented', ?1 FROM profiles", + [subject], + )?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'consented', ?1 FROM mutation_outbox + WHERE profile_id<>''", + [subject], + )?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'consented', ?1 FROM mutation_quarantine + WHERE profile_id<>''", + [subject], + )?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='consented', subject=?1 + WHERE policy='unclaimed'", + [subject], + )?; + } + let profiles_to_upload = { + let mut statement = transaction.prepare( + "SELECT p.id, p.name, p.source_path, p.target_path, p.exclusions_json, + p.created_at, p.updated_at + FROM profiles p + JOIN profile_sync_policy policy ON policy.profile_id=p.id + WHERE policy.policy='consented' AND policy.subject=?1", + )?; + let rows = statement.query_map([subject], |row| { + let exclusions: String = row.get(4)?; + Ok(Profile { + id: row.get(0)?, + name: row.get(1)?, + source_path: row.get(2)?, + target_path: row.get(3)?, + exclusions: serde_json::from_str(&exclusions).unwrap_or_default(), + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + })?; + rows.collect::, _>>()? + }; + for profile in profiles_to_upload { + if validate_profile(&profile).is_err() { + continue; + } + let already_queued: i64 = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?1)", + [&profile.id], + |row| row.get(0), + )?; + if already_queued == 0 { + transaction.execute( + "INSERT INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id, + preserve_on_epoch_adopt + ) VALUES (?1, 'upsert', ?2, ?3, ?4, 1)", + params![ + Uuid::new_v4().to_string(), + serde_json::to_string(&profile).map_err(internal_error)?, + profile.updated_at, + profile.id + ], + )?; + } + } + transaction.execute( + "UPDATE mutation_quarantine SET subject=?1 + WHERE provenance='pre-login' AND profile_id IN ( + SELECT profile_id FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 + )", + [subject], + )?; + } + if same_owner { + let lifecycle_generation = Uuid::new_v4().to_string(); + transaction.execute( + "UPDATE sync_state SET + session_generation=session_generation + 1, + preserve_outbox_on_epoch_adopt=CASE WHEN EXISTS( + SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + UNION ALL + SELECT 1 FROM profile_sync_policy + WHERE policy='consented' AND subject=?2 + ) THEN 1 ELSE preserve_outbox_on_epoch_adopt END, + lifecycle_generation=?1 + WHERE singleton=1 AND subject=?2", + params![lifecycle_generation, subject], + )?; + transaction.commit()?; + return Ok(()); + } + let epoch = Uuid::new_v4().to_string(); + let lifecycle_generation = Uuid::new_v4().to_string(); + transaction.execute( + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, + preserve_outbox_on_epoch_adopt, lifecycle_generation) + VALUES (1, ?1, ?2, '', 1, ?3, ?4) + ON CONFLICT(singleton) DO UPDATE SET subject=excluded.subject, epoch=excluded.epoch, + cursor='', session_generation=sync_state.session_generation + 1, + preserve_outbox_on_epoch_adopt=excluded.preserve_outbox_on_epoch_adopt, + lifecycle_generation=excluded.lifecycle_generation", + params![ + subject, + epoch, + i64::from(upload_existing), + lifecycle_generation + ], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn accept_account_epoch( + &self, + subject: &str, + epoch: &str, + remove_local_profiles: bool, + ) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + let preserve_consented_outbox = !remove_local_profiles + && transaction + .query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + UNION ALL + SELECT 1 FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 + ) FROM sync_state + WHERE singleton=1 AND subject=?1", + [subject], + |row| row.get::<_, i64>(0), + ) + .optional()? + == Some(1); + if preserve_consented_outbox { + transaction.execute( + "DELETE FROM mutation_outbox WHERE preserve_on_epoch_adopt=0", + [], + )?; + } else { + transaction.execute("DELETE FROM mutation_outbox", [])?; + } + if remove_local_profiles { + transaction.execute("DELETE FROM profiles", [])?; + transaction.execute("DELETE FROM mutation_quarantine", [])?; + transaction.execute("DELETE FROM profile_sync_policy", [])?; + } + let lifecycle_generation = Uuid::new_v4().to_string(); + transaction.execute( + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, + preserve_outbox_on_epoch_adopt, lifecycle_generation) + VALUES (1, ?1, ?2, '', 1, 0, ?4) + ON CONFLICT(singleton) DO UPDATE SET subject=excluded.subject, epoch=excluded.epoch, + cursor='', session_generation=sync_state.session_generation + 1, + preserve_outbox_on_epoch_adopt=CASE WHEN ?3 THEN 1 ELSE 0 END, + lifecycle_generation=excluded.lifecycle_generation", + params![ + subject, + epoch, + preserve_consented_outbox, + lifecycle_generation + ], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn preserves_consented_outbox(&self, subject: &str) -> Result { + Ok(self + .connection() + .query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + UNION ALL + SELECT 1 FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 + ) FROM sync_state + WHERE singleton=1 AND subject=?1", + [subject], + |row| row.get::<_, i64>(0), + ) + .optional()? + == Some(1)) + } + + pub fn accept_account_epoch_if_current( + &self, + expected_subject: &str, + expected_epoch: &str, + expected_cursor: &str, + expected_generation: i64, + next_epoch: &str, + remove_local_profiles: bool, + ) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + let current: Option<(String, String, String, i64)> = transaction + .query_row( + "SELECT subject, epoch, cursor, session_generation FROM sync_state WHERE singleton = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + if current.as_ref() + != Some(&( + expected_subject.to_owned(), + expected_epoch.to_owned(), + expected_cursor.to_owned(), + expected_generation, + )) + { + return Err(NativeError::new( + NativeErrorCode::SyncStateChanged, + "Hosted sync state changed; the stale account response was discarded.", + )); + } + transaction.execute("DELETE FROM mutation_outbox", [])?; + if remove_local_profiles { + transaction.execute("DELETE FROM profiles", [])?; + transaction.execute("DELETE FROM mutation_quarantine", [])?; + transaction.execute("DELETE FROM profile_sync_policy", [])?; + } else { + transaction.execute("DELETE FROM profile_sync_policy", [])?; + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + SELECT id, 'local-only', ?1 FROM profiles", + [expected_subject], + )?; + transaction.execute( + "UPDATE mutation_quarantine SET subject=?1 WHERE provenance='pre-login'", + [expected_subject], + )?; + } + let lifecycle_generation = Uuid::new_v4().to_string(); + transaction.execute( + "UPDATE sync_state SET epoch=?1, cursor='', session_generation=session_generation + 1, + preserve_outbox_on_epoch_adopt=0, lifecycle_generation=?6 + WHERE singleton=1 AND subject=?2 AND epoch=?3 AND cursor=?4 AND session_generation=?5", + params![ + next_epoch, + expected_subject, + expected_epoch, + expected_cursor, + expected_generation, + lifecycle_generation + ], + )?; + transaction.commit()?; + Ok(()) + } + + fn build_hosted_sync_request( + &self, + subject: &str, + expected_lifecycle_generation: Option<&str>, + ) -> Result<(serde_json::Value, String), NativeError> { + let mut connection = self.connection(); + let unbound_claim_required_before_quarantine: i64 = connection.query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox + UNION ALL + SELECT 1 FROM profiles + UNION ALL + SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' + UNION ALL + SELECT 1 + FROM mutation_quarantine quarantine + LEFT JOIN profile_sync_policy policy ON policy.profile_id=quarantine.profile_id + WHERE quarantine.provenance='pre-login' + AND (policy.profile_id IS NULL OR policy.policy='unclaimed') + )", + [], + |row| row.get(0), + )?; + quarantine_invalid_outbox(&mut connection)?; + let unresolved_account_claim: i64 = connection.query_row( + "SELECT EXISTS( + SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' + UNION ALL + SELECT 1 + FROM mutation_quarantine quarantine + LEFT JOIN profile_sync_policy policy ON policy.profile_id=quarantine.profile_id + WHERE quarantine.provenance='pre-login' + AND (policy.profile_id IS NULL OR policy.policy='unclaimed') + )", + [], + |row| row.get(0), + )?; + let unbound_account_claim_required = + unbound_claim_required_before_quarantine == 1 || unresolved_account_claim == 1; + let device_id: String = match connection + .query_row( + "SELECT value FROM settings WHERE key='device_id'", + [], + |row| row.get(0), + ) + .optional()? + { + Some(device_id) => device_id, + None => { + let device_id = Uuid::new_v4().to_string(); + connection.execute( + "INSERT INTO settings(key, value) VALUES ('device_id', ?1)", + [&device_id], + )?; + device_id + } + }; + let pending = { + let mut statement = connection.prepare( + "SELECT mutation_id, kind, payload, occurred_at + FROM mutation_outbox ORDER BY sequence ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(OutboxMutation { + mutation_id: row.get(0)?, + kind: row.get(1)?, + payload: row.get(2)?, + occurred_at: row.get(3)?, + }) + })?; + rows.collect::, _>>()? + }; + let current: Option<(String, String, String, String)> = connection + .query_row( + "SELECT subject, epoch, cursor, lifecycle_generation + FROM sync_state WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + let (epoch, cursor, lifecycle_generation) = if let Some(expected_lifecycle_generation) = + expected_lifecycle_generation + { + match current { + Some((owner, epoch, cursor, lifecycle_generation)) + if owner == subject + && lifecycle_generation == expected_lifecycle_generation => + { + (epoch, cursor, lifecycle_generation) + } + _ => { + return Err(NativeError::new( + NativeErrorCode::SyncStateChanged, + "Hosted sync lifecycle changed; the stale request was stopped.", + )) + } + } + } else { + match current { + Some((owner, epoch, cursor, lifecycle_generation)) if owner == subject => { + if unresolved_account_claim == 1 { + return Err(NativeError::new( + NativeErrorCode::SyncAccountClaimRequired, + "Choose whether this account may upload existing local profiles.", + )); + } + (epoch, cursor, lifecycle_generation) + } + Some((owner, _, _, _)) if owner.is_empty() => { + if unbound_account_claim_required { + return Err(NativeError::new( + NativeErrorCode::SyncAccountClaimRequired, + "Choose whether this account may upload existing local profiles.", + )); + } + let epoch = Uuid::new_v4().to_string(); + let lifecycle_generation = Uuid::new_v4().to_string(); + connection.execute( + "UPDATE sync_state SET subject=?1, epoch=?2, cursor='', + session_generation=session_generation + 1, + preserve_outbox_on_epoch_adopt=0, lifecycle_generation=?3 + WHERE singleton=1", + params![subject, epoch, lifecycle_generation], + )?; + (epoch, String::new(), lifecycle_generation) + } + Some(_) => { + return Err(NativeError::new( + NativeErrorCode::AuthRequired, + "Local hosted-sync state belongs to another account. Sign out before switching accounts.", + )) + } + None => { + if unbound_account_claim_required { + return Err(NativeError::new( + NativeErrorCode::SyncAccountClaimRequired, + "Choose whether this account may upload existing local profiles.", + )); + } + let epoch = Uuid::new_v4().to_string(); + let lifecycle_generation = Uuid::new_v4().to_string(); + connection.execute( + "INSERT INTO sync_state( + singleton, subject, epoch, cursor, session_generation, + preserve_outbox_on_epoch_adopt, lifecycle_generation + ) VALUES (1, ?1, ?2, '', 1, 0, ?3)", + params![subject, epoch, lifecycle_generation], + )?; + (epoch, String::new(), lifecycle_generation) + } + } + }; + let mut mutations = Vec::new(); + for mutation in pending.into_iter().take(HOSTED_SYNC_MUTATION_LIMIT) { + let payload: serde_json::Value = + serde_json::from_str(&mutation.payload).map_err(internal_error)?; + let value = if mutation.kind == "upsert" { + json!({ + "mutationId": mutation.mutation_id, + "kind": "upsert", + "profile": payload, + "occurredAt": mutation.occurred_at, + }) + } else { + json!({ + "mutationId": mutation.mutation_id, + "kind": "delete", + "profileId": payload.get("profileId").and_then(serde_json::Value::as_str) + .ok_or_else(|| NativeError::new(NativeErrorCode::Internal, "A local delete mutation is invalid."))?, + "occurredAt": mutation.occurred_at, + }) + }; + mutations.push(value); + let candidate = json!({ + "deviceId": device_id, + "epoch": epoch, + "cursor": cursor, + "mutations": mutations, + }); + if serde_json::to_vec(&candidate) + .map_err(internal_error)? + .len() + > HOSTED_SYNC_BODY_LIMIT + { + mutations.pop(); + if mutations.is_empty() { + return Err(NativeError::new( + NativeErrorCode::Internal, + "A local profile exceeds the hosted sync request limit.", + )); + } + break; + } + } + let mut request = json!({ "deviceId": device_id, "epoch": epoch, "mutations": mutations }); + if !cursor.is_empty() { + request["cursor"] = serde_json::Value::String(cursor); + } + Ok((request, lifecycle_generation)) + } + + fn initial_hosted_sync_request( + &self, + subject: &str, + ) -> Result<(serde_json::Value, String), NativeError> { + self.build_hosted_sync_request(subject, None) + } + + fn hosted_sync_request_for_lifecycle( + &self, + subject: &str, + lifecycle_generation: &str, + ) -> Result<(serde_json::Value, String), NativeError> { + self.build_hosted_sync_request(subject, Some(lifecycle_generation)) + } + + pub fn hosted_sync_request(&self, subject: &str) -> Result { + self.initial_hosted_sync_request(subject) + .map(|(request, _)| request) + } + + pub fn hosted_sync_generation( + &self, + subject: &str, + epoch: &str, + cursor: &str, + ) -> Result { + self.sync_binding()? + .filter(|(owner, current_epoch, current_cursor, _)| { + owner == subject && current_epoch == epoch && current_cursor == cursor + }) + .map(|(_, _, _, generation)| generation) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync state changed; retry with the current account session.", + ) + }) + } + + fn is_hosted_sync_lifecycle_current( + &self, + subject: &str, + epoch: &str, + cursor: &str, + lifecycle_generation: &str, + ) -> Result { + Ok(self + .connection() + .query_row( + "SELECT 1 FROM sync_state + WHERE singleton=1 AND subject=?1 AND epoch=?2 AND cursor=?3 + AND lifecycle_generation=?4", + params![subject, epoch, cursor, lifecycle_generation], + |_| Ok(()), + ) + .optional()? + .is_some()) + } + + pub fn apply_hosted_sync_response( + &self, + expected_subject: &str, + expected_epoch: &str, + expected_cursor: &str, + expected_generation: i64, + response: &serde_json::Value, + ) -> Result<(), NativeError> { + let epoch = response + .get("epoch") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an invalid epoch.", + ) + })?; + let cursor = response + .get("cursor") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an invalid cursor.", + ) + })?; + let records = response + .get("records") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned invalid records.", + ) + })?; + let receipts = response + .get("receipts") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned invalid receipts.", + ) + })?; + + let mut connection = self.connection(); + let transaction = connection.transaction()?; + let current: Option<(String, String, String, i64)> = transaction + .query_row( + "SELECT subject, epoch, cursor, session_generation FROM sync_state WHERE singleton = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + if epoch != expected_epoch + || current.as_ref() + != Some(&( + expected_subject.to_owned(), + expected_epoch.to_owned(), + expected_cursor.to_owned(), + expected_generation, + )) + { + return Err(NativeError::new( + NativeErrorCode::SyncStateChanged, + "Hosted sync state changed; the stale response was discarded.", + )); + } + for receipt in receipts { + let mutation_id = receipt + .get("mutationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "A hosted mutation receipt is invalid.", + ) + })?; + transaction.execute( + "DELETE FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 + AND profile_id=( + SELECT profile_id FROM mutation_outbox WHERE mutation_id=?2 + )", + params![expected_subject, mutation_id], + )?; + transaction.execute( + "DELETE FROM mutation_outbox WHERE mutation_id = ?1", + [mutation_id], + )?; + } + for record in records { + match record.get("kind").and_then(serde_json::Value::as_str) { + Some("profile") => { + let profile: Profile = serde_json::from_value( + record.get("profile").cloned().ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "A hosted profile record is missing.", + ) + })?, + ) + .map_err(internal_error)?; + validate_profile(&profile)?; + let has_pending_local_mutation: i64 = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox WHERE profile_id=?1 + UNION ALL + SELECT 1 FROM mutation_quarantine WHERE profile_id=?1 + UNION ALL + SELECT 1 FROM profile_sync_policy WHERE profile_id=?1 + )", + [&profile.id], + |row| row.get(0), + )?; + if has_pending_local_mutation == 1 { + continue; + } + transaction.execute( + "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, source_path=excluded.source_path, + target_path=excluded.target_path, exclusions_json=excluded.exclusions_json, updated_at=excluded.updated_at", + params![profile.id, profile.name, profile.source_path, profile.target_path, + serde_json::to_string(&profile.exclusions).map_err(internal_error)?, profile.created_at, profile.updated_at], + )?; + } + Some("tombstone") => { + let profile_id = record + .get("profileId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "A hosted tombstone is invalid.", + ) + })?; + let has_pending_local_mutation: i64 = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox WHERE profile_id=?1 + UNION ALL + SELECT 1 FROM mutation_quarantine WHERE profile_id=?1 + UNION ALL + SELECT 1 FROM profile_sync_policy WHERE profile_id=?1 + )", + [profile_id], + |row| row.get(0), + )?; + if has_pending_local_mutation == 1 { + continue; + } + transaction.execute("DELETE FROM profiles WHERE id = ?1", [profile_id])?; + } + _ => { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an unknown record kind.", + )) + } + } + } + let updated = transaction.execute( + "UPDATE sync_state SET cursor = ?1, + preserve_outbox_on_epoch_adopt=CASE + WHEN EXISTS( + SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + UNION ALL + SELECT 1 FROM profile_sync_policy + WHERE policy='consented' AND subject=?2 + ) THEN 1 + ELSE 0 + END + WHERE singleton = 1 AND subject = ?2 AND epoch = ?3 AND cursor = ?4 AND session_generation = ?5", + params![cursor, expected_subject, expected_epoch, expected_cursor, expected_generation], + )?; + if updated != 1 { + return Err(NativeError::new( + NativeErrorCode::SyncStateChanged, + "Hosted sync state changed; the stale response was discarded.", + )); + } + transaction.commit()?; + Ok(()) + } + + pub fn record_run( + &self, + id: &str, + profile_id: &str, + status: &str, + created_count: i64, + result_json: &str, + ) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + transaction.execute( + "INSERT INTO run_history(id, profile_id, status, created_count, result_json) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, profile_id, status, created_count, result_json], + )?; + transaction.execute( + "DELETE FROM run_history WHERE sequence NOT IN (SELECT sequence FROM run_history ORDER BY sequence DESC LIMIT 100)", + [], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn run_history(&self) -> Result, NativeError> { + let connection = self.connection(); + let mut statement = connection.prepare( + "SELECT id, profile_id, status, created_count, result_json FROM run_history ORDER BY sequence DESC LIMIT 100", + )?; + let rows = statement.query_map([], |row| { + Ok(RunRecord { + id: row.get(0)?, + profile_id: row.get(1)?, + status: row.get(2)?, + created_count: row.get(3)?, + result_json: row.get(4)?, + }) + })?; + Ok(rows.collect::, _>>()?) + } +} + +#[tauri::command] +fn choose_folder(_role: String) -> Option { + rfd::FileDialog::new() + .pick_folder() + .map(|path| path.to_string_lossy().into_owned()) +} + +#[tauri::command] +fn inspect_saved_profile_roots( + source_path: PathBuf, + target_path: PathBuf, +) -> Result { + inspect_profile_roots(&source_path, &target_path) +} + +#[tauri::command] +async fn scan_directories( + request: ScanRequest, + operations: State<'_, Arc>, +) -> Result { + let operation_id = request.operation_id.clone(); + let token = operations.begin(&operation_id); + let spawned = tauri::async_runtime::spawn_blocking(move || scan_plan(&request, &token)).await; + operations.finish(&operation_id); + spawned.map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))? +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApplyCommand { + request: ScanRequest, + plan: ScanPlan, + selected: Vec, + profile_id: Option, +} + +fn record_apply_result( + database: &Database, + result: &ApplyResult, + profile_id: Option<&str>, +) -> Result<(), NativeError> { + let created = result + .directories + .iter() + .filter(|entry| entry.status == DirectoryStatus::Created) + .count() as i64; + let payload = serde_json::to_string(&result.directories) + .map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; + let status = if result.cancelled { + "cancelled" + } else if result + .directories + .iter() + .any(|entry| entry.status == DirectoryStatus::Failed) + { + "partial" + } else { + "completed" + }; + database.record_run( + &result.run_id, + profile_id.unwrap_or(""), + status, + created, + &payload, + ) +} + +#[tauri::command] +async fn apply_directories( + command: ApplyCommand, + operations: State<'_, Arc>, + database: State<'_, Database>, +) -> Result { + let operation_id = command.request.operation_id.clone(); + let token = operations.begin(&operation_id); + let spawned = tauri::async_runtime::spawn_blocking(move || { + apply_plan(&command.request, &command.plan, &command.selected, &token) + .map(|result| (result, command.profile_id)) + }) + .await; + operations.finish(&operation_id); + let result = + spawned.map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; + let (result, profile_id) = result?; + record_apply_result(&database, &result, profile_id.as_deref())?; + Ok(result) +} + +#[tauri::command] +fn cancel_operation(operation_id: String, operations: State<'_, Arc>) { + operations.cancel(&operation_id); +} + +#[tauri::command] +fn list_profiles(database: State<'_, Database>) -> Result, NativeError> { + database.list_profiles() +} + +#[tauri::command] +fn save_profile(profile: Profile, database: State<'_, Database>) -> Result { + database.save_profile(&profile)?; + Ok(profile) +} + +#[tauri::command] +fn delete_profile(id: String, database: State<'_, Database>) -> Result<(), NativeError> { + database.delete_profile(&id) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HostedSyncOutcome { + acknowledged: usize, + records_applied: usize, + quarantined_mutations: usize, + cursor: String, +} + +#[derive(Default)] +struct HostedSyncLock(tokio::sync::Mutex<()>); + +fn validate_exact_receipt_ids( + response: &serde_json::Value, + sent_ids: &HashSet, +) -> Result { + let receipts = response + .get("receipts") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned invalid mutation receipts; local data remains queued.", + ) + })?; + let mut received_ids = HashSet::with_capacity(receipts.len()); + for receipt in receipts { + let mutation_id = receipt + .get("mutationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an invalid mutation receipt; local data remains queued.", + ) + })?; + if !received_ids.insert(mutation_id.to_owned()) { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned duplicate mutation receipts; local data remains queued.", + )); + } + } + if &received_ids != sent_ids { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync receipts did not exactly match the sent mutations; local data remains queued.", + )); + } + Ok(receipts.len()) +} + +async fn run_hosted_sync_loop( + database: &Database, + subject: &str, + mut send: Send, +) -> Result +where + Send: FnMut(serde_json::Value) -> Response, + Response: + std::future::Future>, +{ + let mut acknowledged = 0; + let mut records_applied = 0; + let mut lifecycle_generation: Option = None; + let cursor = loop { + let (payload, request_lifecycle_generation) = if let Some(expected_lifecycle_generation) = + lifecycle_generation.as_deref() + { + database.hosted_sync_request_for_lifecycle(subject, expected_lifecycle_generation)? + } else { + database.initial_hosted_sync_request(subject)? + }; + let expected_epoch = payload["epoch"].as_str().unwrap_or_default().to_owned(); + let expected_cursor = payload["cursor"].as_str().unwrap_or_default().to_owned(); + lifecycle_generation = Some(request_lifecycle_generation.clone()); + let expected_generation = + database.hosted_sync_generation(subject, &expected_epoch, &expected_cursor)?; + let sent_ids: HashSet = payload["mutations"] + .as_array() + .into_iter() + .flatten() + .filter_map(|mutation| mutation["mutationId"].as_str().map(ToOwned::to_owned)) + .collect(); + let (status, body) = send(payload).await?; + if !database.is_hosted_sync_lifecycle_current( + subject, + &expected_epoch, + &expected_cursor, + &request_lifecycle_generation, + )? { + return Err(NativeError::new( + NativeErrorCode::SyncStateChanged, + "Hosted sync lifecycle changed; the stale response was stopped.", + )); + } + if status == reqwest::StatusCode::CONFLICT + && body + .get("code") + .and_then(serde_json::Value::as_str) + .is_some_and(|code| code == "RESET_REQUIRED" || code == "SYNC_EPOCH_RESET_REQUIRED") + { + let preserves_consented_outbox = database.preserves_consented_outbox(subject)?; + return Err(NativeError { + code: NativeErrorCode::ResetRequired, + message: if preserves_consented_outbox { + "This account already has hosted data. Review the explicitly consented local profiles before uploading." + } else { + "Hosted profile data was reset. Review this device before uploading again." + } + .into(), + details: body.get("epoch").cloned().map(|epoch| { + json!({ + "epoch": epoch, + "preservesConsentedOutbox": preserves_consented_outbox + }) + }), + }); + } + if !status.is_success() { + return Err(NativeError::new( + if status == reqwest::StatusCode::UNAUTHORIZED { + NativeErrorCode::AuthRequired + } else { + NativeErrorCode::Internal + }, + "Hosted sync was rejected; local data remains safe.", + )); + } + let receipt_count = validate_exact_receipt_ids(&body, &sent_ids)?; + let response_cursor = body + .get("cursor") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + match database.apply_hosted_sync_response( + subject, + &expected_epoch, + &expected_cursor, + expected_generation, + &body, + ) { + Ok(()) => {} + Err(error) if error.code == NativeErrorCode::SyncStateChanged => { + if database.is_hosted_sync_lifecycle_current( + subject, + &expected_epoch, + &expected_cursor, + &request_lifecycle_generation, + )? { + continue; + } + return Err(error); + } + Err(error) => return Err(error), + } + acknowledged += receipt_count; + records_applied += body + .get("records") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + let has_more = body + .get("hasMore") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + if sent_ids.is_empty() { + if has_more { + continue; + } + break response_cursor; + } + let pending: HashSet = database + .pending_outbox()? + .into_iter() + .map(|mutation| mutation.mutation_id) + .collect(); + if sent_ids.iter().any(|id| pending.contains(id)) { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned incomplete mutation receipts; local data remains queued.", + )); + } + if pending.is_empty() && !has_more { + break response_cursor; + } + }; + Ok(HostedSyncOutcome { + acknowledged, + records_applied, + quarantined_mutations: database.quarantined_mutations()?.len(), + cursor, + }) +} + +#[tauri::command] +async fn sync_hosted_profiles( + api_url: String, + access_token: String, + subject: String, + database: State<'_, Database>, + sync_lock: State<'_, HostedSyncLock>, +) -> Result { + let _sync_guard = sync_lock.0.lock().await; + let base = url::Url::parse(&api_url).map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync configuration is invalid.", + ) + })?; + if base.scheme() != "https" || base.host_str().is_none() { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync requires an HTTPS endpoint.", + )); + } + let endpoint = base.join("/v1/sync").map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync configuration is invalid.", + ) + })?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync is unavailable; local data remains safe.", + ) + })?; + run_hosted_sync_loop(&database, &subject, move |payload| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let access_token = access_token.clone(); + async move { + let mut response = client + .post(endpoint) + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync is unavailable; local data remains safe.", + ) + })?; + let status = response.status(); + if response + .content_length() + .is_some_and(|length| length > 2 * 1024 * 1024) + { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an oversized response.", + )); + } + let mut response_bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an invalid response.", + ) + })? { + if response_bytes.len() + chunk.len() > 2 * 1024 * 1024 { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an oversized response.", + )); + } + response_bytes.extend_from_slice(&chunk); + } + let body = serde_json::from_slice(&response_bytes).map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an invalid response.", + ) + })?; + Ok((status, body)) + } + }) + .await +} + +#[tauri::command] +fn clear_local_synced_data(database: State<'_, Database>) -> Result<(), NativeError> { + database.clear_local_synced_data() +} + +#[tauri::command] +fn disconnect_hosted_account( + remove_local_profiles: bool, + database: State<'_, Database>, +) -> Result<(), NativeError> { + database.disconnect_hosted_account(remove_local_profiles) +} + +#[tauri::command] +fn claim_hosted_account( + subject: String, + upload_existing: bool, + database: State<'_, Database>, +) -> Result<(), NativeError> { + database.claim_hosted_account(&subject, upload_existing) +} + +#[tauri::command] +fn accept_hosted_epoch( + subject: String, + epoch: String, + remove_local_profiles: bool, + database: State<'_, Database>, +) -> Result<(), NativeError> { + if subject.is_empty() || Uuid::parse_str(&epoch).is_err() { + return Err(NativeError::new( + NativeErrorCode::ResetRequired, + "Hosted sync returned an invalid account reset.", + )); + } + database.accept_account_epoch(&subject, &epoch, remove_local_profiles) +} + +#[tauri::command] +async fn delete_hosted_account_data( + api_url: String, + access_token: String, + subject: String, + remove_local_profiles: bool, + database: State<'_, Database>, + sync_lock: State<'_, HostedSyncLock>, +) -> Result<(), NativeError> { + let _sync_guard = sync_lock.0.lock().await; + let base = url::Url::parse(&api_url).map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync configuration is invalid.", + ) + })?; + if base.scheme() != "https" || base.host_str().is_none() { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync requires an HTTPS endpoint.", + )); + } + let request = database.hosted_sync_request(&subject)?; + let epoch = request["epoch"].as_str().unwrap_or_default().to_owned(); + let cursor = request["cursor"].as_str().unwrap_or_default().to_owned(); + let generation = database.hosted_sync_generation(&subject, &epoch, &cursor)?; + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted account deletion is unavailable.", + ) + })? + .delete(base.join("/v1/account-data").map_err(internal_error)?) + .bearer_auth(access_token) + .json(&json!({ "epoch": epoch })) + .send() + .await + .map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted account deletion is unavailable.", + ) + })?; + if !response.status().is_success() { + return Err(NativeError::new( + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + NativeErrorCode::AuthRequired + } else { + NativeErrorCode::Internal + }, + "Hosted account deletion was rejected; local data was not changed.", + )); + } + let body: serde_json::Value = response.json().await.map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted account deletion returned an invalid response.", + ) + })?; + let next_epoch = body + .get("epoch") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted account deletion returned an invalid epoch.", + ) + })?; + if Uuid::parse_str(next_epoch).is_err() { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted account deletion returned an invalid epoch.", + )); + } + database.accept_account_epoch_if_current( + &subject, + &epoch, + &cursor, + generation, + next_epoch, + remove_local_profiles, + ) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + } + })) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .setup(|app| { + let directory = app.path().app_data_dir()?; + fs::create_dir_all(&directory)?; + app.handle().plugin( + tauri_plugin_stronghold::Builder::with_argon2( + &directory.join("rootline-auth.salt"), + ) + .build(), + )?; + let database = Database::open(directory.join("rootline.sqlite3")) + .map_err(|error| Box::::from(error.to_string()))?; + database + .device_id() + .map_err(|error| Box::::from(error.to_string()))?; + app.manage(database); + app.manage(HostedSyncLock::default()); + app.manage(Arc::new(OperationRegistry::default())); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + choose_folder, + inspect_saved_profile_roots, + scan_directories, + apply_directories, + cancel_operation, + list_profiles, + save_profile, + delete_profile, + auth_vault_password, + sync_hosted_profiles, + clear_local_synced_data, + disconnect_hosted_account, + claim_hosted_account, + accept_hosted_epoch, + delete_hosted_account_data, + ]) + .run(tauri::generate_context!()) + .expect("error while running Rootline"); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tempfile::tempdir; + use tokio::sync::oneshot; + + #[test] + fn mkdir_reports_already_existing_directories() { + let target = tempdir().unwrap(); + let existing = target.path().join("existing"); + fs::create_dir(&existing).unwrap(); + let result = create_directory_result("existing".into(), &existing); + assert_eq!(result.status, DirectoryStatus::AlreadyExists); + assert!(result.error.is_none()); + } + + #[test] + fn cancelled_apply_results_are_recorded_as_cancelled_history() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("history.sqlite3")).unwrap(); + let result = ApplyResult { + run_id: "cancelled-run".into(), + started_at: "x".into(), + finished_at: "y".into(), + directories: vec![DirectoryResult { + relative_path: "docs".into(), + status: DirectoryStatus::Created, + error: None, + }], + cancelled: true, + }; + record_apply_result(&database, &result, Some("profile-1")).unwrap(); + let history = database.run_history().unwrap(); + assert_eq!(history[0].status, "cancelled"); + assert_eq!(history[0].created_count, 1); + } + + #[test] + fn cancellation_after_a_mkdir_returns_that_accumulated_result_deterministically() { + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir(source.path().join("first")).unwrap(); + fs::create_dir(source.path().join("second")).unwrap(); + let request = ScanRequest { + operation_id: "observed-apply".into(), + source_path: source.path().into(), + target_path: target.path().into(), + exclusions: Vec::new(), + }; + let plan = scan_plan(&request, &CancellationToken::default()).unwrap(); + let token = CancellationToken::default(); + let observer_token = token.clone(); + let result = + apply_plan_with_observer(&request, &plan, &plan.missing, &token, move |entry| { + assert_eq!(entry.relative_path, "first"); + observer_token.cancel(); + }) + .unwrap(); + assert!(result.cancelled); + assert_eq!(result.directories.len(), 1); + assert_eq!(result.directories[0].status, DirectoryStatus::Created); + assert!(target.path().join("first").is_dir()); + assert!(!target.path().join("second").exists()); + } + + #[test] + fn command_loop_stops_after_disconnect_and_allows_bob_without_rebinding_alice() { + for stale_status in [reqwest::StatusCode::OK, reqwest::StatusCode::CONFLICT] { + for remove_local_profiles in [false, true] { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = Arc::new( + Database::open(directory.path().join(format!( + "disconnect-command-loop-{remove_local_profiles}-{}.sqlite3", + stale_status.as_u16() + ))) + .unwrap(), + ); + let profile = Profile { + id: "alice-local".into(), + name: "Alice local".into(), + source_path: "/alice/private".into(), + target_path: "/alice/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + + let alice_sends = Arc::new(AtomicUsize::new(0)); + let (request_started_tx, request_started_rx) = oneshot::channel(); + let (response_tx, response_rx) = oneshot::channel(); + let task_database = Arc::clone(&database); + let task_sends = Arc::clone(&alice_sends); + let sync = tauri::async_runtime::spawn(async move { + let mut request_started_tx = Some(request_started_tx); + let mut response_rx = Some(response_rx); + run_hosted_sync_loop(&task_database, "alice", move |payload| { + task_sends.fetch_add(1, Ordering::SeqCst); + let started = request_started_tx.take(); + let response = response_rx.take(); + async move { + if let Some(started) = started { + let _ = started.send(payload); + } + match response { + Some(response) => response.await.unwrap(), + None => Err(NativeError::new( + NativeErrorCode::Internal, + "Alice transport was reused after disconnect.", + )), + } + } + }) + .await + }); + + let alice_request = request_started_rx.await.unwrap(); + database + .disconnect_hosted_account(remove_local_profiles) + .unwrap(); + response_tx + .send(Ok(( + stale_status, + if stale_status == reqwest::StatusCode::CONFLICT { + json!({ + "code": "SYNC_EPOCH_RESET_REQUIRED", + "epoch": alice_request["epoch"] + }) + } else { + json!({ + "epoch": alice_request["epoch"], + "cursor": "alice-stale", + "hasMore": false, + "records": [], + "receipts": alice_request["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }) + }, + ))) + .unwrap(); + let error = sync.await.unwrap().unwrap_err(); + assert_eq!(error.code, NativeErrorCode::SyncStateChanged); + assert_eq!(alice_sends.load(Ordering::SeqCst), 1); + + database.claim_hosted_account("bob", false).unwrap(); + let bob_sends = Arc::new(AtomicUsize::new(0)); + let counted_bob_sends = Arc::clone(&bob_sends); + run_hosted_sync_loop(&database, "bob", move |payload| { + counted_bob_sends.fetch_add(1, Ordering::SeqCst); + async move { + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": "bob-current", + "hasMore": false, + "records": [], + "receipts": [] + }), + )) + } + }) + .await + .unwrap(); + assert_eq!(bob_sends.load(Ordering::SeqCst), 1); + assert_eq!(database.sync_cursor().unwrap().unwrap().1, "bob-current"); + }); + } + } + } + + #[test] + fn strict_followup_request_cannot_rebind_after_preflight_disconnect_interleaving() { + let directory = tempdir().unwrap(); + let database = + Database::open(directory.path().join("strict-followup-request.sqlite3")).unwrap(); + database.claim_hosted_account("alice", false).unwrap(); + let (_, lifecycle_generation) = database.initial_hosted_sync_request("alice").unwrap(); + + database.disconnect_hosted_account(false).unwrap(); + let error = database + .hosted_sync_request_for_lifecycle("alice", &lifecycle_generation) + .unwrap_err(); + assert_eq!(error.code, NativeErrorCode::SyncStateChanged); + assert!(database.hosted_sync_request("bob").is_ok()); + } + + #[test] + fn command_loop_preflights_lifecycle_before_building_a_followup_page() { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = + Database::open(directory.path().join("post-success-race.sqlite3")).unwrap(); + database.claim_hosted_account("alice", false).unwrap(); + database + .connection() + .execute_batch( + "CREATE TRIGGER disconnect_after_page_one + AFTER UPDATE OF cursor ON sync_state + WHEN NEW.cursor='page-one' + BEGIN + DELETE FROM mutation_outbox; + UPDATE sync_state SET subject='', epoch='', cursor='', + session_generation=session_generation + 1, + lifecycle_generation=lower(hex(randomblob(16))) + WHERE singleton=1; + END;", + ) + .unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let counted_sends = Arc::clone(&sends); + let error = run_hosted_sync_loop(&database, "alice", move |payload| { + let attempt = counted_sends.fetch_add(1, Ordering::SeqCst); + async move { + if attempt > 0 { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Alice follow-up transport ran after lifecycle invalidation.", + )); + } + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": "page-one", + "hasMore": true, + "records": [], + "receipts": [] + }), + )) + } + }) + .await + .unwrap_err(); + + assert_eq!(error.code, NativeErrorCode::SyncStateChanged); + assert_eq!(sends.load(Ordering::SeqCst), 1); + assert!(database.hosted_sync_request("bob").is_ok()); + }); + } + + #[test] + fn command_loop_rejects_same_account_aba_after_disconnect_and_rebind() { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = Arc::new( + Database::open(directory.path().join("same-account-aba.sqlite3")).unwrap(), + ); + database.claim_hosted_account("alice", false).unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let (request_started_tx, request_started_rx) = oneshot::channel(); + let (response_tx, response_rx) = oneshot::channel(); + let task_database = Arc::clone(&database); + let task_sends = Arc::clone(&sends); + let sync = tauri::async_runtime::spawn(async move { + let mut request_started_tx = Some(request_started_tx); + let mut response_rx = Some(response_rx); + run_hosted_sync_loop(&task_database, "alice", move |payload| { + task_sends.fetch_add(1, Ordering::SeqCst); + let started = request_started_tx.take(); + let response = response_rx.take(); + async move { + if let Some(started) = started { + let _ = started.send(payload); + } + match response { + Some(response) => response.await.unwrap(), + None => Err(NativeError::new( + NativeErrorCode::Internal, + "Old Alice transport was reused after account ABA.", + )), + } + } + }) + .await + }); + + let request = request_started_rx.await.unwrap(); + let old_epoch = request["epoch"].as_str().unwrap(); + database.disconnect_hosted_account(false).unwrap(); + database.claim_hosted_account("alice", false).unwrap(); + database + .accept_account_epoch("alice", old_epoch, false) + .unwrap(); + response_tx + .send(Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": old_epoch, + "cursor": "stale-aba", + "hasMore": false, + "records": [], + "receipts": [] + }), + ))) + .unwrap(); + + let error = sync.await.unwrap().unwrap_err(); + assert_eq!(error.code, NativeErrorCode::SyncStateChanged); + assert_eq!(sends.load(Ordering::SeqCst), 1); + }); + } + + #[test] + fn command_loop_retries_a_same_subject_edit_generation() { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = Arc::new( + Database::open(directory.path().join("edit-command-loop.sqlite3")).unwrap(), + ); + let profile = Profile { + id: "alice-edit".into(), + name: "Before request".into(), + source_path: "/alice/source".into(), + target_path: "/alice/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let transport_database = Arc::clone(&database); + let counted_sends = Arc::clone(&sends); + let edited_profile = Profile { + name: "Edited during request".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + ..profile + }; + + run_hosted_sync_loop(&database, "alice", move |payload| { + let attempt = counted_sends.fetch_add(1, Ordering::SeqCst); + let transport_database = Arc::clone(&transport_database); + let edited_profile = edited_profile.clone(); + async move { + if attempt == 0 { + transport_database.save_profile(&edited_profile).unwrap(); + } + let receipts = payload["mutations"] + .as_array() + .unwrap() + .iter() + .enumerate() + .map(|(index, mutation)| { + json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }) + .collect::>(); + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": format!("edit-attempt-{attempt}"), + "hasMore": false, + "records": [], + "receipts": receipts + }), + )) + } + }) + .await + .unwrap(); + + assert_eq!(sends.load(Ordering::SeqCst), 2); + assert_eq!( + database.list_profiles().unwrap()[0].name, + "Edited during request" + ); + assert!(database.pending_outbox().unwrap().is_empty()); + }); + } + + fn assert_invalid_receipt_set_preserves_outbox(fault: &str) { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = Database::open( + directory + .path() + .join(format!("invalid-{fault}-receipts.sqlite3")), + ) + .unwrap(); + for index in 0..101 { + database + .enqueue_mutation( + &format!("00000000-0000-4000-8006-{index:012}"), + "upsert", + &json!({ + "id": format!("profile-{index}"), + "name": format!("Profile {index}"), + "sourcePath": format!("/source/{index}"), + "targetPath": format!("/target/{index}"), + "exclusions": [], + "createdAt": "2026-08-15T00:00:00Z", + "updatedAt": "2026-08-15T00:00:00Z" + }) + .to_string(), + "2026-08-15T00:00:00Z", + ) + .unwrap(); + } + database.claim_hosted_account("alice", true).unwrap(); + let before = database.pending_outbox().unwrap(); + let before_cursor = database.sync_cursor().unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let counted_sends = Arc::clone(&sends); + let fault = fault.to_owned(); + + let error = run_hosted_sync_loop(&database, "alice", move |payload| { + let attempt = counted_sends.fetch_add(1, Ordering::SeqCst); + let fault = fault.clone(); + async move { + if attempt > 0 { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Transport was reused after an invalid receipt set.", + )); + } + let mutations = payload["mutations"].as_array().unwrap(); + assert_eq!(mutations.len(), 100); + let mut receipts = mutations + .iter() + .enumerate() + .map(|(index, mutation)| { + json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }) + .collect::>(); + match fault.as_str() { + "missing" => { + receipts.pop(); + } + "duplicate" => receipts.push(receipts[0].clone()), + "extra-101" => receipts.push(json!({ + "mutationId": "00000000-0000-4000-8006-000000000100", + "revision": 101 + })), + _ => unreachable!(), + } + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": "must-not-commit", + "hasMore": false, + "records": [], + "receipts": receipts + }), + )) + } + }) + .await + .unwrap_err(); + + assert_eq!(error.code, NativeErrorCode::Internal); + assert_eq!(sends.load(Ordering::SeqCst), 1); + assert_eq!(database.pending_outbox().unwrap(), before); + assert_eq!(database.sync_cursor().unwrap(), before_cursor); + }); + } + + #[test] + fn command_loop_rejects_missing_receipts_before_mutating_sqlite() { + assert_invalid_receipt_set_preserves_outbox("missing"); + } + + #[test] + fn command_loop_rejects_duplicate_receipts_before_mutating_sqlite() { + assert_invalid_receipt_set_preserves_outbox("duplicate"); + } + + #[test] + fn command_loop_rejects_an_extra_101st_receipt_before_mutating_sqlite() { + assert_invalid_receipt_set_preserves_outbox("extra-101"); + } + + #[test] + fn command_loop_reports_quarantined_legacy_mutations_without_fifo_wedging() { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = + Database::open(directory.path().join("quarantine-status.sqlite3")).unwrap(); + database + .enqueue_mutation( + "00000000-0000-4000-8007-000000000010", + "upsert", + &json!({ + "id": "legacy-invalid", + "name": "n".repeat(81), + "sourcePath": "/source", + "targetPath": "/target", + "exclusions": [], + "createdAt": "2026-08-15T00:00:00Z", + "updatedAt": "2026-08-15T00:00:00Z" + }) + .to_string(), + "2026-08-15T00:00:00Z", + ) + .unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + + let outcome = run_hosted_sync_loop(&database, "alice", |payload| async move { + assert_eq!(payload["mutations"], json!([])); + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": "quarantine-observed", + "hasMore": false, + "records": [], + "receipts": [] + }), + )) + }) + .await + .unwrap(); + + assert_eq!(outcome.quarantined_mutations, 1); + assert!(database.pending_outbox().unwrap().is_empty()); + assert_eq!(database.quarantined_mutations().unwrap().len(), 1); + }); + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..5e8c789 --- /dev/null +++ b/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + rootline_desktop::run(); +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..f67e7ec --- /dev/null +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Rootline by baole.space", + "version": "2.0.0", + "identifier": "space.baole.rootline", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://127.0.0.1:1420", + "beforeBuildCommand": "pnpm --dir ../.. build:workspace-deps && pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "Rootline by baole.space", + "width": 1240, + "height": 800, + "minWidth": 880, + "minHeight": 620, + "resizable": true + } + ], + "security": { "csp": null } + }, + "bundle": { + "active": true, + "createUpdaterArtifacts": true, + "targets": "all", + "icon": [], + "category": "Utility", + "shortDescription": "Safe, additive folder-structure synchronization" + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["rootline"] + } + }, + "updater": { + "endpoints": [ + "https://github.com/unique01082/folder-structure-sync/releases/latest/download/latest.json" + ], + "pubkey": "" + } + } +} diff --git a/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs b/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs new file mode 100644 index 0000000..d9346a6 --- /dev/null +++ b/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs @@ -0,0 +1,106 @@ +use rootline_desktop::{Database, Profile}; +use serde_json::Value; +use tempfile::tempdir; + +fn post_sync( + client: &reqwest::blocking::Client, + api_url: &str, + token: &str, + body: &Value, +) -> reqwest::blocking::Response { + client + .post(format!("{api_url}/v1/sync")) + .bearer_auth(token) + .json(body) + .send() + .unwrap() +} + +fn exact_code_points(count: usize) -> String { + format!( + "{}{}", + "✈️".repeat(count / 2), + if count % 2 == 1 { "x" } else { "" } + ) +} + +#[test] +#[ignore = "run by the real PostgreSQL API harness"] +fn replays_a_consented_device_two_outbox_without_leaking_paths_to_another_account() { + let api_url = std::env::var("ROOTLINE_E2E_API_URL").expect("ROOTLINE_E2E_API_URL"); + let alice_token = std::env::var("ROOTLINE_E2E_ALICE_TOKEN").expect("ROOTLINE_E2E_ALICE_TOKEN"); + let bob_token = std::env::var("ROOTLINE_E2E_BOB_TOKEN").expect("ROOTLINE_E2E_BOB_TOKEN"); + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("device-two.sqlite3")).unwrap(); + let local = Profile { + id: "device-two-offline-profile".into(), + name: exact_code_points(80), + source_path: exact_code_points(4096), + target_path: exact_code_points(4096), + exclusions: (0..100).map(|_| exact_code_points(256)).collect(), + created_at: "2026-08-15T00:00:00.000Z".into(), + updated_at: "2026-08-15T00:00:00.000Z".into(), + }; + database.save_profile(&local).unwrap(); + database.claim_hosted_account("seam-alice", true).unwrap(); + let client = reqwest::blocking::Client::new(); + + let first = database.hosted_sync_request("seam-alice").unwrap(); + let conflict = post_sync(&client, &api_url, &alice_token, &first); + assert_eq!(conflict.status(), reqwest::StatusCode::CONFLICT); + let server_epoch = conflict.json::().unwrap()["epoch"] + .as_str() + .unwrap() + .to_owned(); + database + .accept_account_epoch("seam-alice", &server_epoch, false) + .unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + + let reconnect = database.hosted_sync_request("seam-alice").unwrap(); + assert_eq!(reconnect["mutations"][0]["profile"]["name"], local.name); + assert_eq!( + reconnect["mutations"][0]["profile"]["sourcePath"], + local.source_path + ); + let generation = database + .hosted_sync_generation("seam-alice", &server_epoch, "") + .unwrap(); + let response = post_sync(&client, &api_url, &alice_token, &reconnect); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body = response.json::().unwrap(); + assert_eq!(body["receipts"].as_array().unwrap().len(), 1); + database + .apply_hosted_sync_response("seam-alice", &server_epoch, "", generation, &body) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + let persisted = database + .list_profiles() + .unwrap() + .into_iter() + .find(|profile| profile.id == local.id) + .expect("accepted Unicode profile must reach desktop SQLite"); + assert_eq!(persisted, local); + assert_eq!(database.sync_cursor().unwrap().unwrap().1, body["cursor"]); + + database.disconnect_hosted_account(false).unwrap(); + assert_eq!( + database.hosted_sync_request("seam-bob").unwrap_err().code, + rootline_desktop::NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("seam-bob", false).unwrap(); + let bob = database.hosted_sync_request("seam-bob").unwrap(); + assert_eq!(bob["mutations"], serde_json::json!([])); + assert!(!bob.to_string().contains(&local.source_path)); + let bob_epoch = bob["epoch"].as_str().unwrap().to_owned(); + let bob_generation = database + .hosted_sync_generation("seam-bob", &bob_epoch, "") + .unwrap(); + let response = post_sync(&client, &api_url, &bob_token, &bob); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let bob_body = response.json::().unwrap(); + assert_eq!(bob_body["records"], serde_json::json!([])); + database + .apply_hosted_sync_response("seam-bob", &bob_epoch, "", bob_generation, &bob_body) + .unwrap(); +} diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs new file mode 100644 index 0000000..44f3134 --- /dev/null +++ b/apps/desktop/src-tauri/tests/native.rs @@ -0,0 +1,2073 @@ +use std::fs; + +use rootline_desktop::{ + apply_plan, detect_case_sensitive, inspect_profile_roots, random_vault_password, + resolve_existing_vault_password, scan_plan, CancellationToken, Database, DiffStatus, + DirectoryStatus, NativeErrorCode, Profile, ScanRequest, +}; +use rusqlite::Connection; +use tempfile::tempdir; + +fn request(source: &std::path::Path, target: &std::path::Path) -> ScanRequest { + ScanRequest { + operation_id: "scan-1".into(), + source_path: source.to_path_buf(), + target_path: target.to_path_buf(), + exclusions: vec![".git".into()], + } +} + +#[test] +fn saved_profile_root_inspection_is_read_only_and_reports_each_missing_root() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("target"); + fs::create_dir(&source).unwrap(); + let sentinel = source.join("sentinel"); + fs::write(&sentinel, "unchanged").unwrap(); + + let availability = inspect_profile_roots(&source, &target).unwrap(); + + assert!(availability.source_available); + assert!(!availability.target_available); + assert_eq!(fs::read_to_string(sentinel).unwrap(), "unchanged"); + assert!(!target.exists()); +} + +fn create_v5_database_with_invalid_profile(path: &std::path::Path, profile: &Profile) { + let connection = Connection::open(path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys = ON; + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + {} + INSERT INTO schema_migrations(version) VALUES (1); + {} + INSERT INTO schema_migrations(version) VALUES (2); + {} + INSERT INTO schema_migrations(version) VALUES (3); + {} + INSERT INTO schema_migrations(version) VALUES (4); + {} + INSERT INTO schema_migrations(version) VALUES (5);", + include_str!("../migrations/0001_offline_state.sql"), + include_str!("../migrations/0002_account_scoped_sync.sql"), + include_str!("../migrations/0003_sync_session_generation.sql"), + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + )) + .unwrap(); + connection + .execute( + "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, '[]', ?5, ?6)", + rusqlite::params![ + profile.id, + profile.name, + profile.source_path, + profile.target_path, + profile.created_at, + profile.updated_at + ], + ) + .unwrap(); + connection + .execute( + "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at, profile_id) + VALUES (?1, 'upsert', ?2, ?3, ?4)", + rusqlite::params![ + "00000000-0000-4000-8007-000000000001", + serde_json::to_string(profile).unwrap(), + profile.updated_at, + profile.id + ], + ) + .unwrap(); +} + +fn create_v6_database(path: &std::path::Path) -> Connection { + let connection = Connection::open(path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys = ON; + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + {} + INSERT INTO schema_migrations(version) VALUES (1); + {} + INSERT INTO schema_migrations(version) VALUES (2); + {} + INSERT INTO schema_migrations(version) VALUES (3); + {} + INSERT INTO schema_migrations(version) VALUES (4); + {} + INSERT INTO schema_migrations(version) VALUES (5); + {} + INSERT INTO schema_migrations(version) VALUES (6);", + include_str!("../migrations/0001_offline_state.sql"), + include_str!("../migrations/0002_account_scoped_sync.sql"), + include_str!("../migrations/0003_sync_session_generation.sql"), + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + include_str!("../migrations/0006_invalid_outbox_quarantine.sql"), + )) + .unwrap(); + connection +} + +fn insert_legacy_profile(connection: &Connection, profile: &Profile) { + connection + .execute( + "INSERT INTO profiles( + id, name, source_path, target_path, exclusions_json, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + profile.id, + profile.name, + profile.source_path, + profile.target_path, + serde_json::to_string(&profile.exclusions).unwrap(), + profile.created_at, + profile.updated_at + ], + ) + .unwrap(); +} + +fn insert_legacy_sync_state(connection: &Connection, subject: &str) { + connection + .execute( + "INSERT INTO sync_state(singleton, epoch, cursor, subject) + VALUES (1, '00000000-0000-4000-8000-000000000600', '', ?1)", + [subject], + ) + .unwrap(); +} + +#[test] +fn scans_additively_and_revalidates_before_mkdir() { + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir_all(source.path().join("docs/api")).unwrap(); + fs::create_dir_all(source.path().join("src/components")).unwrap(); + fs::create_dir_all(source.path().join(".git/objects")).unwrap(); + + let cancellation = CancellationToken::default(); + let plan = scan_plan(&request(source.path(), target.path()), &cancellation).unwrap(); + assert_eq!(plan.missing, ["docs", "docs/api", "src", "src/components"]); + + let result = apply_plan( + &request(source.path(), target.path()), + &plan, + &["docs/api".into()], + &cancellation, + ) + .unwrap(); + assert!(target.path().join("docs/api").is_dir()); + assert_eq!(result.directories.len(), 2); + + fs::create_dir(source.path().join("changed-after-review")).unwrap(); + let error = apply_plan( + &request(source.path(), target.path()), + &plan, + &plan.missing, + &cancellation, + ) + .unwrap_err(); + assert_eq!(error.code, NativeErrorCode::StalePlan); +} + +#[cfg(unix)] +#[test] +fn reports_missing_exists_excluded_and_unreadable_diff_states_with_full_globs() { + use std::os::unix::fs::PermissionsExt; + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir_all(source.path().join("docs/api")).unwrap(); + fs::create_dir(source.path().join("shared")).unwrap(); + fs::create_dir_all(source.path().join("generated/deep/cache")).unwrap(); + fs::create_dir(source.path().join("app1.log")).unwrap(); + fs::create_dir(source.path().join("app10.log")).unwrap(); + let locked = source.path().join("locked"); + fs::create_dir(&locked).unwrap(); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap(); + fs::create_dir(target.path().join("shared")).unwrap(); + + let mut request = request(source.path(), target.path()); + request.exclusions = vec!["generated/**/cache".into(), "app?.log".into()]; + let result = scan_plan(&request, &CancellationToken::default()); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap(); + let plan = result.unwrap(); + + let status = |path: &str| { + plan.diff_entries + .iter() + .find(|entry| entry.relative_path == path) + .map(|entry| entry.status) + }; + assert_eq!(status("docs"), Some(DiffStatus::Missing)); + assert_eq!(status("shared"), Some(DiffStatus::Exists)); + assert_eq!(status("generated/deep/cache"), Some(DiffStatus::Excluded)); + assert_eq!(status("app1.log"), Some(DiffStatus::Excluded)); + assert_eq!(status("app10.log"), Some(DiffStatus::Missing)); + assert_eq!(status("locked"), Some(DiffStatus::Unreadable)); +} + +#[test] +fn binds_a_plan_to_its_canonical_roots_even_when_snapshots_match() { + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + let other_source = tempdir().unwrap(); + let other_target = tempdir().unwrap(); + fs::create_dir(source.path().join("docs")).unwrap(); + fs::create_dir(other_source.path().join("docs")).unwrap(); + + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + assert_eq!(plan.source_root, fs::canonicalize(source.path()).unwrap()); + assert_eq!(plan.target_root, fs::canonicalize(target.path()).unwrap()); + + let error = apply_plan( + &request(other_source.path(), other_target.path()), + &plan, + &plan.missing, + &CancellationToken::default(), + ) + .unwrap_err(); + assert_eq!(error.code, NativeErrorCode::StalePlan); + assert!(!other_target.path().join("docs").exists()); +} + +#[test] +fn reports_case_semantics_and_failed_directory_creation() { + let case_root = tempdir().unwrap(); + let case_sensitive = detect_case_sensitive(case_root.path()).unwrap(); + fs::write(case_root.path().join("CaseProbe"), b"x").unwrap(); + assert_eq!(case_root.path().join("caseprobe").exists(), !case_sensitive); + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir(source.path().join("blocked")).unwrap(); + fs::write(target.path().join("blocked"), b"not a directory").unwrap(); + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + let result = apply_plan( + &request(source.path(), target.path()), + &plan, + &["blocked".into()], + &CancellationToken::default(), + ) + .unwrap(); + assert_eq!(result.directories[0].status, DirectoryStatus::Failed); + assert!(result.directories[0].error.is_some()); +} + +#[test] +fn rejects_overlapping_roots_and_honors_cancellation() { + let source = tempdir().unwrap(); + fs::create_dir(source.path().join("child")).unwrap(); + let overlap = request(source.path(), &source.path().join("child")); + assert_eq!( + scan_plan(&overlap, &CancellationToken::default()) + .unwrap_err() + .code, + NativeErrorCode::PathOverlap, + ); + + let target = tempdir().unwrap(); + let cancelled = CancellationToken::default(); + cancelled.cancel(); + assert_eq!( + scan_plan(&request(source.path(), target.path()), &cancelled) + .unwrap_err() + .code, + NativeErrorCode::Cancelled, + ); +} + +#[cfg(unix)] +#[test] +fn case_detection_is_read_only_and_overlap_wins_before_target_inspection() { + use std::os::unix::fs::PermissionsExt; + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir_all(source.path().join("docs/api")).unwrap(); + fs::create_dir(target.path().join("existing-directory")).unwrap(); + fs::write(target.path().join("keep.bin"), [0, 1, 2, 255]).unwrap(); + let before_entries = fs::read_dir(target.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + let before_bytes = fs::read(target.path().join("keep.bin")).unwrap(); + let original_mode = fs::metadata(target.path()).unwrap().permissions().mode(); + fs::set_permissions(target.path(), fs::Permissions::from_mode(0o555)).unwrap(); + + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + assert_eq!(plan.missing, ["docs", "docs/api"]); + + fs::set_permissions(target.path(), fs::Permissions::from_mode(original_mode)).unwrap(); + let after_entries = fs::read_dir(target.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(after_entries, before_entries); + assert_eq!( + fs::read(target.path().join("keep.bin")).unwrap(), + before_bytes + ); + + let nested_target = source.path().join("nested-target"); + fs::create_dir(&nested_target).unwrap(); + fs::write(nested_target.join("sentinel"), b"unchanged").unwrap(); + fs::set_permissions(&nested_target, fs::Permissions::from_mode(0o555)).unwrap(); + let error = scan_plan( + &request(source.path(), &nested_target), + &CancellationToken::default(), + ) + .unwrap_err(); + fs::set_permissions(&nested_target, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(error.code, NativeErrorCode::PathOverlap); + assert_eq!( + fs::read(nested_target.join("sentinel")).unwrap(), + b"unchanged" + ); + + let implementation = include_str!("../src/lib.rs"); + assert!(!implementation.contains(".rootline-case-probe")); + assert!(!implementation.contains("create_new(true)")); + assert!(!implementation.contains("validate_relationship(&source, &target, !cfg!")); +} + +#[test] +fn actual_volume_metadata_controls_case_distinct_sibling_overlap() { + let parent = tempdir().unwrap(); + let case_sensitive = detect_case_sensitive(parent.path()).unwrap(); + let source = parent.path().join("Foo"); + let target = parent.path().join("foo"); + fs::create_dir(&source).unwrap(); + fs::create_dir(source.join("nested")).unwrap(); + + if case_sensitive { + fs::create_dir(&target).unwrap(); + let plan = scan_plan(&request(&source, &target), &CancellationToken::default()).unwrap(); + assert_eq!(plan.missing, ["nested"]); + assert!(plan.target_case_sensitive); + } else { + assert_eq!( + scan_plan(&request(&source, &target), &CancellationToken::default()) + .unwrap_err() + .code, + NativeErrorCode::PathOverlap, + ); + } +} + +#[cfg(unix)] +#[test] +fn skips_symbolic_links_instead_of_following_them() { + use std::os::unix::fs::symlink; + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::create_dir(outside.path().join("secret")).unwrap(); + symlink(outside.path(), source.path().join("linked")).unwrap(); + + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + assert!(plan.missing.is_empty()); + assert_eq!(plan.skipped_links, ["linked"]); + + let linked_root = source.path().join("linked-root"); + symlink(outside.path(), &linked_root).unwrap(); + assert_eq!( + scan_plan( + &request(&linked_root, target.path()), + &CancellationToken::default(), + ) + .unwrap_err() + .code, + NativeErrorCode::InvalidPath, + ); + + let parent = tempdir().unwrap(); + let real_ancestor = tempdir().unwrap(); + fs::create_dir(real_ancestor.path().join("source")).unwrap(); + let linked_ancestor = parent.path().join("linked-ancestor"); + symlink(real_ancestor.path(), &linked_ancestor).unwrap(); + assert_eq!( + scan_plan( + &request(&linked_ancestor.join("source"), target.path()), + &CancellationToken::default(), + ) + .unwrap_err() + .code, + NativeErrorCode::InvalidPath, + ); +} + +#[cfg(windows)] +#[test] +fn skips_windows_junctions_and_rejects_a_junction_root() { + use std::process::Command; + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::create_dir(outside.path().join("secret")).unwrap(); + let junction = source.path().join("junction"); + let status = Command::new("cmd.exe") + .arg("/C") + .arg("mklink") + .arg("/J") + .arg(&junction) + .arg(outside.path()) + .status() + .unwrap(); + assert!(status.success()); + + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + assert!(plan.missing.is_empty()); + assert_eq!(plan.skipped_links, ["junction"]); + assert_eq!( + scan_plan( + &request(&junction, target.path()), + &CancellationToken::default(), + ) + .unwrap_err() + .code, + NativeErrorCode::InvalidPath, + ); +} + +#[test] +fn migrates_and_persists_offline_state_with_bounded_history() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("rootline.sqlite3")).unwrap(); + let device_id = database.device_id().unwrap(); + assert_eq!(database.device_id().unwrap(), device_id); + + let profile = Profile { + id: "profile-1".into(), + name: "Work".into(), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![".git".into()], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + assert_eq!(database.list_profiles().unwrap(), [profile]); + let saved_mutations = database.pending_outbox().unwrap(); + assert_eq!(saved_mutations.len(), 1); + assert_eq!(saved_mutations[0].kind, "upsert"); + database + .acknowledge_mutations(&[saved_mutations[0].mutation_id.clone()]) + .unwrap(); + + database + .enqueue_mutation( + "mutation-1", + "upsert", + "{\"id\":\"profile-1\"}", + "2026-08-15T00:00:00Z", + ) + .unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + database + .acknowledge_mutations(&["mutation-1".into()]) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + database.set_sync_cursor("epoch-1", "cursor-7").unwrap(); + assert_eq!( + database.sync_cursor().unwrap(), + Some(("epoch-1".into(), "cursor-7".into())) + ); + + for index in 0..105 { + database + .record_run( + &format!("run-{index:03}"), + "profile-1", + "completed", + index, + "[]", + ) + .unwrap(); + } + let history = database.run_history().unwrap(); + assert_eq!(history.len(), 100); + assert_eq!(history.first().unwrap().id, "run-104"); + assert_eq!(history.last().unwrap().id, "run-005"); + + database.delete_profile("profile-1").unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + let deleted_mutations = database.pending_outbox().unwrap(); + assert_eq!(deleted_mutations.len(), 1); + assert_eq!(deleted_mutations[0].kind, "delete"); + drop(database); + + let reopened = Database::open(directory.path().join("rootline.sqlite3")).unwrap(); + assert_eq!(reopened.device_id().unwrap(), device_id); +} + +#[test] +fn native_profile_limits_are_enforced_before_profile_or_outbox_persistence() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("profile-limits.sqlite3")).unwrap(); + let exact_code_points = |count: usize| { + format!( + "{}{}", + "✈️".repeat(count / 2), + if count % 2 == 1 { "x" } else { "" } + ) + }; + let boundary = Profile { + id: "boundary".into(), + name: exact_code_points(80), + source_path: exact_code_points(4096), + target_path: exact_code_points(4096), + exclusions: (0..100).map(|_| exact_code_points(256)).collect(), + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&boundary).unwrap(); + let before_profiles = database.list_profiles().unwrap(); + let before_outbox = database.pending_outbox().unwrap(); + + let invalid = [ + Profile { + id: "bad-name".into(), + name: exact_code_points(81), + ..boundary.clone() + }, + Profile { + id: "bad-source".into(), + source_path: String::new(), + ..boundary.clone() + }, + Profile { + id: "bad-target".into(), + target_path: exact_code_points(4097), + ..boundary.clone() + }, + Profile { + id: "bad-count".into(), + exclusions: (0..101).map(|_| "x".into()).collect(), + ..boundary.clone() + }, + Profile { + id: "bad-pattern".into(), + exclusions: vec![exact_code_points(257)], + ..boundary.clone() + }, + ]; + for profile in invalid { + assert_eq!( + database.save_profile(&profile).unwrap_err().code, + NativeErrorCode::ValidationFailed + ); + assert_eq!(database.list_profiles().unwrap(), before_profiles); + assert_eq!(database.pending_outbox().unwrap(), before_outbox); + } +} + +#[test] +fn v5_upgrade_quarantines_invalid_outbox_without_removing_local_profiles_and_recovers_on_save() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v5-invalid-outbox.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys = ON; + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + {} + INSERT INTO schema_migrations(version) VALUES (1); + {} + INSERT INTO schema_migrations(version) VALUES (2); + {} + INSERT INTO schema_migrations(version) VALUES (3); + {} + INSERT INTO schema_migrations(version) VALUES (4); + {} + INSERT INTO schema_migrations(version) VALUES (5);", + include_str!("../migrations/0001_offline_state.sql"), + include_str!("../migrations/0002_account_scoped_sync.sql"), + include_str!("../migrations/0003_sync_session_generation.sql"), + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + )) + .unwrap(); + let invalid_profile = Profile { + id: "legacy-invalid".into(), + name: "n".repeat(81), + source_path: "/legacy/source".into(), + target_path: "/legacy/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + connection + .execute( + "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, '[]', ?5, ?6)", + rusqlite::params![ + invalid_profile.id, + invalid_profile.name, + invalid_profile.source_path, + invalid_profile.target_path, + invalid_profile.created_at, + invalid_profile.updated_at + ], + ) + .unwrap(); + connection + .execute( + "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at, profile_id) + VALUES (?1, 'upsert', ?2, ?3, ?4)", + rusqlite::params![ + "00000000-0000-4000-8007-000000000001", + serde_json::to_string(&invalid_profile).unwrap(), + invalid_profile.updated_at, + invalid_profile.id + ], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + assert_eq!( + database.list_profiles().unwrap().as_slice(), + std::slice::from_ref(&invalid_profile) + ); + assert!(database.pending_outbox().unwrap().is_empty()); + let quarantined = database.quarantined_mutations().unwrap(); + assert_eq!(quarantined.len(), 1); + assert_eq!(quarantined[0].profile_id, invalid_profile.id); + assert!(quarantined[0].reason.contains("name")); + assert_eq!(quarantined[0].provenance, "pre-login"); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", true).unwrap(); + let consented_empty = database.hosted_sync_request("alice").unwrap(); + assert_eq!(consented_empty["mutations"], serde_json::json!([])); + let local_epoch = consented_empty["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &local_epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &local_epoch, + "", + generation, + &serde_json::json!({ + "epoch": local_epoch, "cursor": "empty-consented", "records": [], "receipts": [] + }), + ) + .unwrap(); + assert!(database.preserves_consented_outbox("alice").unwrap()); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000701", false) + .unwrap(); + + let corrected = Profile { + name: "Corrected".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + ..invalid_profile + }; + database.save_profile(&corrected).unwrap(); + assert!(database.quarantined_mutations().unwrap().is_empty()); + assert_eq!(database.list_profiles().unwrap(), [corrected]); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!(database.pending_outbox().unwrap().len(), 1); +} + +#[test] +fn v6_upgrade_backfills_existing_prelogin_quarantine_provenance() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-quarantine.sqlite3"); + let connection = create_v6_database(&path); + connection + .execute( + "INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) + VALUES ('00000000-0000-4000-8007-000000000006', 'upsert', + 'legacy-prelogin', 'legacy invalid profile')", + [], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + let quarantined = database.quarantined_mutations().unwrap(); + assert_eq!(quarantined.len(), 1); + assert_eq!(quarantined[0].profile_id, "legacy-prelogin"); + assert_eq!(quarantined[0].provenance, "pre-login"); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired, + ); +} + +#[test] +fn v6_unbound_valid_outbox_keep_local_never_leaks_after_edit_delete_epoch_or_switch() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-valid-outbox-keep.sqlite3"); + let profile = Profile { + id: "v6-private-profile".into(), + name: "Legacy private".into(), + source_path: "/Users/alice/private/v6-source".into(), + target_path: "/Volumes/alice/private/v6-target".into(), + exclusions: vec!["secret-*".into()], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, ""); + connection + .execute( + "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at, profile_id) + VALUES (?1, 'upsert', ?2, ?3, ?4)", + rusqlite::params![ + "00000000-0000-4000-8007-000000000061", + serde_json::to_string(&profile).unwrap(), + profile.updated_at, + profile.id + ], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + let migrated_policy: (String, String) = Connection::open(&path) + .unwrap() + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(migrated_policy, ("unclaimed".into(), String::new())); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", false).unwrap(); + let mut edited = profile.clone(); + edited.name = "Private edit".into(); + edited.updated_at = "2026-08-15T00:01:00Z".into(); + database.save_profile(&edited).unwrap(); + let alice = database.hosted_sync_request("alice").unwrap(); + assert_eq!(alice["mutations"], serde_json::json!([])); + assert!(!alice.to_string().contains("/Users/alice/private")); + + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000611", false) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + database.disconnect_hosted_account(false).unwrap(); + assert_eq!( + database.hosted_sync_request("bob").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("bob", false).unwrap(); + edited.name = "Private after switch".into(); + edited.updated_at = "2026-08-15T00:02:00Z".into(); + database.save_profile(&edited).unwrap(); + assert_eq!( + database.hosted_sync_request("bob").unwrap()["mutations"], + serde_json::json!([]) + ); + database.delete_profile(&edited.id).unwrap(); + let after_delete = database.hosted_sync_request("bob").unwrap(); + assert_eq!(after_delete["mutations"], serde_json::json!([])); + assert!(!after_delete.to_string().contains("/Users/alice/private")); +} + +#[test] +fn v6_unbound_retained_profile_without_outbox_requires_a_fresh_claim() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-retained-no-outbox.sqlite3"); + let profile = Profile { + id: "v6-retained-only".into(), + name: "Retained only".into(), + source_path: "/Users/legacy/retained".into(), + target_path: "/Volumes/legacy/retained".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, ""); + insert_legacy_profile(&connection, &profile); + drop(connection); + + let database = Database::open(&path).unwrap(); + let migrated_policy: (String, String) = Connection::open(&path) + .unwrap() + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(migrated_policy, ("unclaimed".into(), String::new())); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", false).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); +} + +#[test] +fn v6_bound_cloud_state_does_not_prompt_without_ambiguous_quarantine() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-bound-clean.sqlite3"); + let profile = Profile { + id: "v6-bound-clean".into(), + name: "Already synced".into(), + source_path: "/cloud/source".into(), + target_path: "/cloud/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, "alice"); + insert_legacy_profile(&connection, &profile); + drop(connection); + + let database = Database::open(&path).unwrap(); + let policy_count: i64 = Connection::open(&path) + .unwrap() + .query_row( + "SELECT COUNT(*) FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(policy_count, 0); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); +} + +#[test] +fn v6_quarantine_is_ambiguous_even_with_a_populated_subject_and_keep_resolves_it() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-bound-quarantine-keep.sqlite3"); + let profile = Profile { + id: "v6-bound-ambiguous".into(), + name: "Ambiguous local".into(), + source_path: "/Users/alice/ambiguous".into(), + target_path: "/Volumes/alice/ambiguous".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, "alice"); + insert_legacy_profile(&connection, &profile); + connection + .execute( + "INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) + VALUES ('00000000-0000-4000-8007-000000000062', 'upsert', ?1, 'legacy')", + [&profile.id], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + let migrated_policy: (String, String) = Connection::open(&path) + .unwrap() + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(migrated_policy, ("unclaimed".into(), String::new())); + assert_eq!( + database.quarantined_mutations().unwrap()[0].provenance, + "pre-login" + ); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", false).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); +} + +#[test] +fn v6_bound_quarantine_upload_consent_survives_epoch_until_correction() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-bound-quarantine-upload.sqlite3"); + let invalid = Profile { + id: "v6-bound-upload".into(), + name: "n".repeat(81), + source_path: "/Users/alice/upload-consented".into(), + target_path: "/Volumes/alice/upload-consented".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, "alice"); + insert_legacy_profile(&connection, &invalid); + connection + .execute( + "INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) + VALUES ('00000000-0000-4000-8007-000000000063', 'upsert', ?1, 'legacy')", + [&invalid.id], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", true).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); + assert!(database.preserves_consented_outbox("alice").unwrap()); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000612", false) + .unwrap(); + + let corrected = Profile { + name: "Corrected after consent".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + ..invalid + }; + database.save_profile(&corrected).unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + assert_eq!(request["mutations"].as_array().unwrap().len(), 1); + assert_eq!( + request["mutations"][0]["profile"]["sourcePath"], + corrected.source_path + ); +} + +#[test] +fn keep_local_only_survives_quarantine_correction_epoch_and_account_switch_without_path_leakage() { + let directory = tempdir().unwrap(); + let path = directory.path().join("quarantine-local-only.sqlite3"); + let invalid = Profile { + id: "legacy-private".into(), + name: "n".repeat(81), + source_path: "/Users/alice/private/source".into(), + target_path: "/Volumes/alice/private/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + create_v5_database_with_invalid_profile(&path, &invalid); + let database = Database::open(&path).unwrap(); + + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", false).unwrap(); + let corrected = Profile { + name: "Corrected local only".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + ..invalid + }; + database.save_profile(&corrected).unwrap(); + assert!(database.quarantined_mutations().unwrap().is_empty()); + let alice = database.hosted_sync_request("alice").unwrap(); + assert_eq!(alice["mutations"], serde_json::json!([])); + assert!(!alice.to_string().contains("/Users/alice/private")); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000702", false) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + + database.disconnect_hosted_account(false).unwrap(); + assert_eq!( + database.hosted_sync_request("bob").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("bob", false).unwrap(); + let mut bob_correction = corrected.clone(); + bob_correction.name = "Still local only".into(); + bob_correction.updated_at = "2026-08-15T00:02:00Z".into(); + database.save_profile(&bob_correction).unwrap(); + let bob = database.hosted_sync_request("bob").unwrap(); + assert_eq!(bob["mutations"], serde_json::json!([])); + assert!(!bob.to_string().contains("/Users/alice/private")); +} + +#[test] +fn quarantined_profile_blocks_remote_upsert_and_tombstone_until_corrected_save_and_delete() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("quarantine-conflict.sqlite3")).unwrap(); + let mut local = Profile { + id: "quarantine-conflict".into(), + name: "Preserved local".into(), + source_path: "/local/source".into(), + target_path: "/local/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&local).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let initial = database.hosted_sync_request("alice").unwrap(); + let epoch = initial["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "baseline", "records": [], + "receipts": [{ "mutationId": initial["mutations"][0]["mutationId"], "revision": 1 }] + }), + ) + .unwrap(); + let invalid = Profile { + name: "n".repeat(81), + ..local.clone() + }; + database + .enqueue_mutation( + "00000000-0000-4000-8007-000000000099", + "upsert", + &serde_json::to_string(&invalid).unwrap(), + &invalid.updated_at, + ) + .unwrap(); + let quarantined_request = database.hosted_sync_request("alice").unwrap(); + assert_eq!(quarantined_request["mutations"], serde_json::json!([])); + assert_eq!(database.quarantined_mutations().unwrap().len(), 1); + + let generation = database + .hosted_sync_generation("alice", &epoch, "baseline") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "baseline", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "remote-upsert", "receipts": [], + "records": [{ + "kind": "profile", "revision": 2, + "profile": { + "id": local.id, "name": "Remote overwrite", "sourcePath": "/remote/source", + "targetPath": "/remote/target", "exclusions": [], + "createdAt": local.created_at, "updatedAt": "2026-08-15T01:00:00Z" + } + }] + }), + ) + .unwrap(); + assert_eq!(database.list_profiles().unwrap(), [local.clone()]); + + let generation = database + .hosted_sync_generation("alice", &epoch, "remote-upsert") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "remote-upsert", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "remote-delete", "receipts": [], + "records": [{ "kind": "tombstone", "revision": 3, "profileId": local.id }] + }), + ) + .unwrap(); + assert_eq!(database.list_profiles().unwrap(), [local.clone()]); + + local.name = "Corrected local".into(); + local.updated_at = "2026-08-15T02:00:00Z".into(); + database.save_profile(&local).unwrap(); + assert!(database.quarantined_mutations().unwrap().is_empty()); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + database.delete_profile(&local.id).unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + assert_eq!( + database.pending_outbox().unwrap().last().unwrap().kind, + "delete" + ); +} + +#[test] +fn deleting_a_quarantined_profile_clears_its_actionable_status_and_queues_a_tombstone() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("quarantine-delete.sqlite3")).unwrap(); + let profile_id = "legacy-invalid"; + let invalid = Profile { + id: profile_id.into(), + name: "n".repeat(81), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database + .enqueue_mutation( + "00000000-0000-4000-8007-000000000099", + "upsert", + &serde_json::to_string(&invalid).unwrap(), + &invalid.updated_at, + ) + .unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", true).unwrap(); + database.hosted_sync_request("alice").unwrap(); + assert_eq!(database.quarantined_mutations().unwrap().len(), 1); + + database.delete_profile(profile_id).unwrap(); + assert!(database.quarantined_mutations().unwrap().is_empty()); + let pending = database.pending_outbox().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].kind, "delete"); +} + +#[test] +fn generates_independent_per_install_vault_passwords() { + let first = random_vault_password(); + let second = random_vault_password(); + assert_eq!(first.len(), 64); + assert!(first.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_ne!(first, second); +} + +#[test] +fn refuses_to_replace_a_missing_os_key_for_an_existing_stronghold_snapshot() { + assert_eq!( + resolve_existing_vault_password(Err(keyring::Error::NoEntry), true) + .unwrap_err() + .code, + NativeErrorCode::Internal, + ); + assert_eq!( + resolve_existing_vault_password(Err(keyring::Error::NoEntry), false).unwrap(), + None, + ); + assert_eq!( + resolve_existing_vault_password(Ok("a".repeat(64)), true).unwrap(), + Some("a".repeat(64)), + ); +} + +#[test] +fn clearing_synced_local_data_is_explicit_and_keeps_run_history() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("clear.sqlite3")).unwrap(); + let profile = Profile { + id: "00000000-0000-4000-8000-000000000001".into(), + name: "Local".into(), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database + .record_run("run", &profile.id, "completed", 1, "[]") + .unwrap(); + database + .set_sync_cursor("00000000-0000-4000-8000-000000000001", "cursor") + .unwrap(); + let invalid_profile = Profile { + name: "n".repeat(81), + ..profile.clone() + }; + database + .enqueue_mutation( + "00000000-0000-4000-8007-000000000100", + "upsert", + &serde_json::to_string(&invalid_profile).unwrap(), + &invalid_profile.updated_at, + ) + .unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let _ = database.hosted_sync_request("alice").unwrap(); + assert_eq!(database.quarantined_mutations().unwrap().len(), 1); + + database.clear_local_synced_data().unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + assert!(database.pending_outbox().unwrap().is_empty()); + assert!(database.quarantined_mutations().unwrap().is_empty()); + assert_eq!(database.sync_cursor().unwrap(), None); + assert_eq!(database.run_history().unwrap().len(), 1); +} + +#[test] +fn hosted_outbox_contains_only_profiles_and_applies_receipts_transactionally() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("hosted.sqlite3")).unwrap(); + let profile = Profile { + id: "00000000-0000-4000-8000-000000000010".into(), + name: "Local".into(), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![".git".into()], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database + .record_run("private-run", &profile.id, "completed", 7, "secret history") + .unwrap(); + database.claim_hosted_account("subject", true).unwrap(); + let request = database.hosted_sync_request("subject").unwrap(); + let serialized = serde_json::to_string(&request).unwrap(); + assert!(serialized.contains("sourcePath")); + assert!(!serialized.contains("private-run")); + assert!(!serialized.contains("secret history")); + let mutation_id = request["mutations"][0]["mutationId"] + .as_str() + .unwrap() + .to_owned(); + let epoch = request["epoch"].as_str().unwrap().to_owned(); + database.apply_hosted_sync_response("subject", &epoch, "", 1, &serde_json::json!({ + "epoch": epoch, + "cursor": "cursor-1", + "records": [{ + "kind": "profile", "revision": 1, + "profile": { "id": profile.id, "name": "Remote arrival", "sourcePath": "/source", "targetPath": "/target", + "exclusions": [], "createdAt": profile.created_at, "updatedAt": "2026-08-15T01:00:00Z", "syncMode": "additive" } + }], + "receipts": [{ "mutationId": mutation_id, "revision": 1 }] + })).unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + assert_eq!( + database.sync_cursor().unwrap(), + Some((request["epoch"].as_str().unwrap().into(), "cursor-1".into())) + ); + assert_eq!(database.list_profiles().unwrap()[0].name, "Remote arrival"); + assert_eq!(database.run_history().unwrap()[0].id, "private-run"); +} + +#[test] +fn invalid_hosted_profile_is_rejected_before_profile_or_cursor_persistence() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("invalid-hosted.sqlite3")).unwrap(); + let request = database.hosted_sync_request("subject").unwrap(); + let epoch = request["epoch"].as_str().unwrap(); + let generation = database + .hosted_sync_generation("subject", epoch, "") + .unwrap(); + + let error = database + .apply_hosted_sync_response( + "subject", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "must-not-commit", + "records": [{ + "kind": "profile", + "revision": 1, + "profile": { + "id": "invalid-hosted-profile", + "name": "n".repeat(81), + "sourcePath": "/source", + "targetPath": "/target", + "exclusions": [], + "createdAt": "2026-08-15T00:00:00Z", + "updatedAt": "2026-08-15T00:00:00Z" + } + }], + "receipts": [] + }), + ) + .unwrap_err(); + + assert_eq!(error.code, NativeErrorCode::ValidationFailed); + assert!(database.list_profiles().unwrap().is_empty()); + assert_eq!( + database.sync_cursor().unwrap(), + Some((epoch.into(), String::new())) + ); +} + +#[test] +fn accepting_a_rotated_account_epoch_drops_stale_outbox_and_respects_local_choice() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("epoch.sqlite3")).unwrap(); + let profile = Profile { + id: "00000000-0000-4000-8000-000000000020".into(), + name: "Keep me".into(), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + + database + .accept_account_epoch("subject", "00000000-0000-4000-8000-000000000021", false) + .unwrap(); + assert_eq!(database.list_profiles().unwrap().first(), Some(&profile)); + assert!(database.pending_outbox().unwrap().is_empty()); + assert_eq!( + database.sync_cursor().unwrap(), + Some(("00000000-0000-4000-8000-000000000021".into(), String::new())) + ); + + database.save_profile(&profile).unwrap(); + database + .accept_account_epoch("subject", "00000000-0000-4000-8000-000000000022", true) + .unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + assert!(database.pending_outbox().unwrap().is_empty()); +} + +#[test] +fn hosted_outbox_batches_offline_replay_within_api_count_and_body_limits() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("batch.sqlite3")).unwrap(); + for index in 0..105 { + database + .enqueue_mutation( + &format!("00000000-0000-4000-8001-{index:012}"), + "upsert", + &serde_json::json!({ + "id": format!("00000000-0000-4000-8002-{index:012}"), + "name": "Offline", + "sourcePath": format!("/source/{}", "s".repeat(4080)), + "targetPath": format!("/target/{}", "t".repeat(4080)), + "exclusions": [], + "createdAt": "2026-08-15T00:00:00Z", + "updatedAt": "2026-08-15T00:00:00Z", + }) + .to_string(), + "2026-08-15T00:00:00Z", + ) + .unwrap(); + } + database.claim_hosted_account("subject", true).unwrap(); + + let mut acknowledged = 0; + let mut batches = 0; + while !database.pending_outbox().unwrap().is_empty() { + let request = database.hosted_sync_request("subject").unwrap(); + let mutations = request["mutations"].as_array().unwrap(); + assert!(!mutations.is_empty()); + assert!(mutations.len() <= 100); + assert!(serde_json::to_vec(&request).unwrap().len() <= 256 * 1024); + acknowledged += mutations.len(); + batches += 1; + let receipts: Vec<_> = mutations + .iter() + .enumerate() + .map(|(index, mutation)| { + serde_json::json!({ + "mutationId": mutation["mutationId"], + "revision": acknowledged - mutations.len() + index + 1, + }) + }) + .collect(); + let expected_cursor = request["cursor"].as_str().unwrap_or_default().to_owned(); + let generation = database + .hosted_sync_generation( + "subject", + request["epoch"].as_str().unwrap(), + &expected_cursor, + ) + .unwrap(); + database + .apply_hosted_sync_response( + "subject", + request["epoch"].as_str().unwrap(), + &expected_cursor, + generation, + &serde_json::json!({ + "epoch": request["epoch"], + "cursor": format!("batch-{batches}"), + "records": [], + "receipts": receipts, + }), + ) + .unwrap(); + } + assert_eq!(acknowledged, 105); + assert!(batches > 1); +} + +#[test] +fn first_batch_response_cannot_overwrite_a_same_profile_edit_queued_in_the_next_batch() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("batch-edit-fence.sqlite3")).unwrap(); + database.hosted_sync_request("alice").unwrap(); + let mut profile = Profile { + id: "batch-profile".into(), + name: String::new(), + source_path: "/batch/source".into(), + target_path: "/batch/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + for index in 0..=100 { + profile.name = format!("Local {index}"); + profile.updated_at = format!("2026-08-15T00:{:02}:00Z", index % 60); + database.save_profile(&profile).unwrap(); + } + let first = database.hosted_sync_request("alice").unwrap(); + assert_eq!(first["mutations"].as_array().unwrap().len(), 100); + let epoch = first["epoch"].as_str().unwrap(); + let generation = database.hosted_sync_generation("alice", epoch, "").unwrap(); + let server_profile = first["mutations"][99]["profile"].clone(); + database + .apply_hosted_sync_response( + "alice", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "batch-one", + "hasMore": false, + "records": [{ "kind": "profile", "revision": 100, "profile": server_profile }], + "receipts": first["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + serde_json::json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }), + ) + .unwrap(); + assert_eq!(database.list_profiles().unwrap()[0].name, "Local 100"); + assert_eq!(database.pending_outbox().unwrap().len(), 1); +} + +#[test] +fn first_batch_response_cannot_resurrect_a_delete_queued_in_the_next_batch() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("batch-delete-fence.sqlite3")).unwrap(); + database.hosted_sync_request("alice").unwrap(); + let mut profile = Profile { + id: "batch-profile".into(), + name: String::new(), + source_path: "/batch/source".into(), + target_path: "/batch/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + for index in 0..100 { + profile.name = format!("Local {index}"); + profile.updated_at = format!("2026-08-15T00:{:02}:00Z", index % 60); + database.save_profile(&profile).unwrap(); + } + database.delete_profile(&profile.id).unwrap(); + let first = database.hosted_sync_request("alice").unwrap(); + assert_eq!(first["mutations"].as_array().unwrap().len(), 100); + let epoch = first["epoch"].as_str().unwrap(); + let generation = database.hosted_sync_generation("alice", epoch, "").unwrap(); + let server_profile = first["mutations"][99]["profile"].clone(); + database + .apply_hosted_sync_response( + "alice", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "batch-one", + "hasMore": false, + "records": [{ "kind": "profile", "revision": 100, "profile": server_profile }], + "receipts": first["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + serde_json::json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }), + ) + .unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + let pending = database.pending_outbox().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].kind, "delete"); +} + +#[test] +fn delayed_sync_responses_are_fenced_after_disconnect_switch_reset_and_out_of_order_apply() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("fenced.sqlite3")).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let alice = database.hosted_sync_request("alice").unwrap(); + let alice_epoch = alice["epoch"].as_str().unwrap().to_owned(); + let alice_generation = database + .hosted_sync_generation("alice", &alice_epoch, "") + .unwrap(); + let alice_response = serde_json::json!({ + "epoch": alice_epoch, + "cursor": "alice-cursor", + "records": [{ + "kind": "profile", "revision": 1, + "profile": { + "id": "alice-remote", "name": "Alice remote", "sourcePath": "/alice/private", + "targetPath": "/alice/target", "exclusions": [], + "createdAt": "2026-08-15T00:00:00Z", "updatedAt": "2026-08-15T00:00:00Z" + } + }], + "receipts": [] + }); + + database.disconnect_hosted_account(true).unwrap(); + database.claim_hosted_account("bob", false).unwrap(); + assert!(database + .apply_hosted_sync_response( + "alice", + alice["epoch"].as_str().unwrap(), + "", + alice_generation, + &alice_response + ) + .is_err()); + assert!(database.list_profiles().unwrap().is_empty()); + assert!(database.hosted_sync_request("bob").is_ok()); + + let bob = database.hosted_sync_request("bob").unwrap(); + let bob_epoch = bob["epoch"].as_str().unwrap().to_owned(); + let bob_generation = database + .hosted_sync_generation("bob", &bob_epoch, "") + .unwrap(); + database + .accept_account_epoch("bob", "00000000-0000-4000-8000-000000000099", false) + .unwrap(); + assert!(database + .apply_hosted_sync_response( + "bob", + &bob_epoch, + "", + bob_generation, + &serde_json::json!({ + "epoch": bob_epoch, "cursor": "stale", "records": [], "receipts": [] + }) + ) + .is_err()); + assert_eq!( + database.sync_cursor().unwrap(), + Some(("00000000-0000-4000-8000-000000000099".into(), String::new())) + ); + + let current = database.hosted_sync_request("bob").unwrap(); + let current_epoch = current["epoch"].as_str().unwrap().to_owned(); + let current_generation = database + .hosted_sync_generation("bob", ¤t_epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "bob", + ¤t_epoch, + "", + current_generation, + &serde_json::json!({ + "epoch": current_epoch, "cursor": "newer", "records": [], "receipts": [] + }), + ) + .unwrap(); + assert!(database + .apply_hosted_sync_response( + "bob", + current["epoch"].as_str().unwrap(), + "", + current_generation, + &serde_json::json!({ + "epoch": current["epoch"], "cursor": "older", "records": [], "receipts": [] + }) + ) + .is_err()); + assert_eq!(database.sync_cursor().unwrap().unwrap().1, "newer"); + + let deletion_generation = database + .hosted_sync_generation("bob", current["epoch"].as_str().unwrap(), "newer") + .unwrap(); + database.disconnect_hosted_account(false).unwrap(); + database.claim_hosted_account("carol", false).unwrap(); + assert!(database + .accept_account_epoch_if_current( + "bob", + current["epoch"].as_str().unwrap(), + "newer", + deletion_generation, + "00000000-0000-4000-8000-000000000100", + true, + ) + .is_err()); + assert!(database.hosted_sync_request("carol").is_ok()); +} + +#[test] +fn claiming_the_same_subject_twice_preserves_the_committed_epoch() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("claim-idempotent.sqlite3")).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let epoch = database.hosted_sync_request("alice").unwrap()["epoch"] + .as_str() + .unwrap() + .to_owned(); + database.claim_hosted_account("alice", false).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["epoch"], + epoch + ); +} + +#[test] +fn local_save_invalidates_an_inflight_response_before_it_can_overwrite_the_new_profile() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("save-race.sqlite3")).unwrap(); + let mut profile = Profile { + id: "profile-race-save".into(), + name: "Before request".into(), + source_path: "/new/local/source".into(), + target_path: "/new/local/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + let epoch = request["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + + profile.name = "Edited while response was delayed".into(); + profile.updated_at = "2026-08-15T01:00:00Z".into(); + database.save_profile(&profile).unwrap(); + let stale = serde_json::json!({ + "epoch": epoch, "cursor": "stale-save", "hasMore": false, + "records": [{ + "kind": "profile", "revision": 1, + "profile": { + "id": profile.id, "name": "Remote stale value", "sourcePath": "/remote/stale", + "targetPath": "/remote/stale", "exclusions": [], + "createdAt": profile.created_at, "updatedAt": "2026-08-14T00:00:00Z" + } + }], + "receipts": [{ "mutationId": request["mutations"][0]["mutationId"], "revision": 1 }] + }); + assert_eq!( + database + .apply_hosted_sync_response("alice", &epoch, "", generation, &stale) + .unwrap_err() + .code, + NativeErrorCode::SyncStateChanged, + ); + assert_eq!(database.list_profiles().unwrap(), [profile.clone()]); + let replay = database.hosted_sync_request("alice").unwrap(); + assert_eq!(replay["mutations"].as_array().unwrap().len(), 2); + assert!(replay + .to_string() + .contains("Edited while response was delayed")); +} + +#[test] +fn local_delete_invalidates_an_inflight_profile_before_it_can_resurrect_the_profile() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("delete-race.sqlite3")).unwrap(); + let profile = Profile { + id: "profile-race-delete".into(), + name: "Delete locally".into(), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let initial = database.hosted_sync_request("alice").unwrap(); + let epoch = initial["epoch"].as_str().unwrap().to_owned(); + let initial_generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "", + initial_generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "cursor-before-delete", "hasMore": false, + "records": [], + "receipts": [{ "mutationId": initial["mutations"][0]["mutationId"], "revision": 1 }] + }), + ) + .unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + let generation = database + .hosted_sync_generation("alice", &epoch, "cursor-before-delete") + .unwrap(); + + database.delete_profile(&profile.id).unwrap(); + assert_eq!( + database + .apply_hosted_sync_response( + "alice", + &epoch, + "cursor-before-delete", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "stale-delete", "hasMore": false, + "records": [{ "kind": "profile", "revision": 2, "profile": profile }], + "receipts": [] + }) + ) + .unwrap_err() + .code, + NativeErrorCode::SyncStateChanged, + ); + assert!(database.list_profiles().unwrap().is_empty()); + let replay = database.hosted_sync_request("alice").unwrap(); + assert_eq!(replay["mutations"][0]["kind"], "delete"); + assert_eq!(request["mutations"], serde_json::json!([])); +} + +#[test] +fn adopting_an_existing_server_epoch_preserves_only_explicitly_consented_unclaimed_outbox() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("epoch-adoption.sqlite3")).unwrap(); + let profile = Profile { + id: "device-two-local".into(), + name: "Explicitly consented".into(), + source_path: "/device-two/private".into(), + target_path: "/device-two/backup".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000111", false) + .unwrap(); + let adopted = database.hosted_sync_request("alice").unwrap(); + assert_eq!(adopted["mutations"].as_array().unwrap().len(), 1); + assert!(adopted.to_string().contains("/device-two/private")); + + let epoch = adopted["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "cloud-owned", "hasMore": false, "records": [], + "receipts": [{ "mutationId": adopted["mutations"][0]["mutationId"], "revision": 1 }] + }), + ) + .unwrap(); + database + .save_profile(&Profile { + name: "Cloud-owned edit".into(), + ..profile + }) + .unwrap(); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000112", false) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); +} + +#[test] +fn epoch_adoption_keeps_only_unreceipted_consented_mutations_and_drops_new_cloud_edits() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("partial-consent.sqlite3")).unwrap(); + for id in ["consented-one", "consented-two"] { + database + .save_profile(&Profile { + id: id.into(), + name: id.into(), + source_path: format!("/{id}/source"), + target_path: format!("/{id}/target"), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }) + .unwrap(); + } + database.claim_hosted_account("alice", true).unwrap(); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000121", false) + .unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + let epoch = request["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "partial", "hasMore": false, "records": [], + "receipts": [{ "mutationId": request["mutations"][0]["mutationId"], "revision": 1 }] + }), + ) + .unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + database + .save_profile(&Profile { + id: "cloud-owned-new-edit".into(), + name: "Cloud-owned new edit".into(), + source_path: "/cloud/source".into(), + target_path: "/cloud/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:01:00Z".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + }) + .unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 2); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000122", false) + .unwrap(); + let preserved = database.pending_outbox().unwrap(); + assert_eq!(preserved.len(), 1); + assert!(preserved[0].payload.contains("consented-two")); + assert!(!preserved[0].payload.contains("cloud-owned-new-edit")); +} + +#[test] +fn editing_a_consented_profile_before_epoch_adoption_preserves_the_newer_edit() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("consented-edit.sqlite3")).unwrap(); + let mut profile = Profile { + id: "consented-profile".into(), + name: "Old value".into(), + source_path: "/old/source".into(), + target_path: "/old/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + profile.name = "New value".into(); + profile.updated_at = "2026-08-15T00:01:00Z".into(); + database.save_profile(&profile).unwrap(); + + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000131", false) + .unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + assert_eq!(request["mutations"].as_array().unwrap().len(), 2); + assert_eq!(request["mutations"][1]["profile"]["name"], "New value"); + let epoch = request["epoch"].as_str().unwrap(); + let generation = database.hosted_sync_generation("alice", epoch, "").unwrap(); + database + .apply_hosted_sync_response( + "alice", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "edited", + "hasMore": false, + "records": [{ "kind": "profile", "revision": 2, "profile": profile }], + "receipts": request["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + serde_json::json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }), + ) + .unwrap(); + assert_eq!(database.list_profiles().unwrap()[0].name, "New value"); + assert!(database.pending_outbox().unwrap().is_empty()); +} + +#[test] +fn deleting_a_consented_profile_before_epoch_adoption_preserves_the_tombstone() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("consented-delete.sqlite3")).unwrap(); + let profile = Profile { + id: "consented-profile".into(), + name: "Delete me".into(), + source_path: "/delete/source".into(), + target_path: "/delete/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + database.delete_profile(&profile.id).unwrap(); + + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000132", false) + .unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + assert_eq!(request["mutations"].as_array().unwrap().len(), 2); + assert_eq!(request["mutations"][1]["kind"], "delete"); + assert_eq!(request["mutations"][1]["profileId"], profile.id); + assert!(database.list_profiles().unwrap().is_empty()); + let epoch = request["epoch"].as_str().unwrap(); + let generation = database.hosted_sync_generation("alice", epoch, "").unwrap(); + database + .apply_hosted_sync_response( + "alice", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "deleted", + "hasMore": false, + "records": [{ "kind": "tombstone", "revision": 2, "profileId": profile.id }], + "receipts": request["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + serde_json::json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }), + ) + .unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + assert!(database.pending_outbox().unwrap().is_empty()); +} + +#[test] +fn hosted_state_cannot_cross_accounts_and_signout_quarantines_pending_paths() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("accounts.sqlite3")).unwrap(); + let profile = Profile { + id: "profile-from-alice".into(), + name: "Alice private path".into(), + source_path: "/Users/alice/private".into(), + target_path: "/Volumes/alice".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + assert_eq!( + database + .hosted_sync_request("account-alice") + .unwrap_err() + .code, + NativeErrorCode::SyncAccountClaimRequired, + ); + database + .claim_hosted_account("account-alice", true) + .unwrap(); + let alice = database.hosted_sync_request("account-alice").unwrap(); + assert!(alice.to_string().contains("/Users/alice/private")); + assert_eq!( + database + .hosted_sync_request("account-bob") + .unwrap_err() + .code, + NativeErrorCode::AuthRequired, + ); + + database.disconnect_hosted_account(false).unwrap(); + assert_eq!(database.list_profiles().unwrap(), [profile]); + assert_eq!( + database + .hosted_sync_request("account-bob") + .unwrap_err() + .code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("account-bob", false).unwrap(); + let bob = database.hosted_sync_request("account-bob").unwrap(); + assert_eq!(bob["mutations"], serde_json::json!([])); + assert!(uuid::Uuid::parse_str(bob["epoch"].as_str().unwrap()).is_ok()); + assert!(!bob.to_string().contains("/Users/alice/private")); +} + +#[test] +fn upgrades_task3_database_and_claims_opaque_profile_ids_only_by_consent() { + let directory = tempdir().unwrap(); + let path = directory.path().join("task3-upgrade.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys = ON; + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + {} + INSERT INTO schema_migrations(version) VALUES (1);", + include_str!("../migrations/0001_offline_state.sql") + )) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + let profile = Profile { + id: "profile-task3-existing".into(), + name: "Task 3 profile".into(), + source_path: "/legacy/source".into(), + target_path: "/legacy/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired, + ); + database.claim_hosted_account("alice", false).unwrap(); + assert_eq!(database.list_profiles().unwrap(), [profile]); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]), + ); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx new file mode 100644 index 0000000..1379c1a --- /dev/null +++ b/apps/desktop/src/App.tsx @@ -0,0 +1,566 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { validateSyncProfile } from "@rootline/contracts"; + +import { DiffTree } from "./components/DiffTree"; +import { AuthControls } from "./components/AuthControls"; +import type { AuthController, ProfileSyncCoordinator } from "./auth"; +import { copy, type Locale } from "./i18n"; +import { + nativeFailure, + tauriGateway, + type ApplyResult, + type NativeGateway, + type Profile, + type ScanPlan, + type ScanRequest, +} from "./native"; + +type Step = "choose" | "scanning" | "review" | "applying" | "result"; +interface RebindState { + source: boolean; + target: boolean; +} + +const noRebind: RebindState = { source: false, target: false }; + +interface AppProps { + gateway?: NativeGateway; + initialProfile?: Profile; + auth?: AuthController; + syncCoordinator?: ProfileSyncCoordinator; +} + +const defaultExclusions = [".git", ".svn", ".hg", "node_modules", ".DS_Store", "Thumbs.db", "dist", "build"]; + +function operationId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function Mark(): React.JSX.Element { + return ( + + ); +} + +export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordinator }: AppProps): React.JSX.Element { + const [locale, setLocale] = useState("en"); + const text = copy[locale]; + const [profiles, setProfiles] = useState(initialProfile ? [initialProfile] : []); + const [activeProfile, setActiveProfile] = useState(initialProfile); + const activeProfileRef = useRef(initialProfile); + const [profileName, setProfileName] = useState(initialProfile?.name ?? ""); + const [sourcePath, setSourcePath] = useState(initialProfile?.sourcePath ?? ""); + const [targetPath, setTargetPath] = useState(initialProfile?.targetPath ?? ""); + const [step, setStep] = useState("choose"); + const [plan, setPlan] = useState(); + const [selected, setSelected] = useState>(new Set()); + const [result, setResult] = useState(); + const [error, setError] = useState(); + const [rebind, setRebind] = useState(noRebind); + const [activeOperation, setActiveOperation] = useState(); + const [deleteCandidate, setDeleteCandidate] = useState(); + const headingRef = useRef(null); + const scanButtonRef = useRef(null); + const newProfileRef = useRef(null); + const deleteDialogRef = useRef(null); + const deleteCancelRef = useRef(null); + const deleteReturnFocusRef = useRef(null); + const returnToScanRef = useRef(false); + const profileInspectionRef = useRef(0); + const profileNameId = useId(); + const deleteDialogTitleId = useId(); + + const inspectSavedProfile = (profile: Profile): void => { + const inspection = ++profileInspectionRef.current; + setRebind(noRebind); + void gateway.inspectProfileRoots({ sourcePath: profile.sourcePath, targetPath: profile.targetPath }).then((availability) => { + if (profileInspectionRef.current !== inspection) return; + setRebind({ source: !availability.sourceAvailable, target: !availability.targetAvailable }); + }).catch(() => { + if (profileInspectionRef.current === inspection) setRebind({ source: true, target: true }); + }); + }; + + const reconcileProfiles = (loaded: Profile[]): void => { + setProfiles(loaded); + const current = activeProfileRef.current; + if (!current) return; + const replacement = loaded.find((profile) => profile.id === current.id); + activeProfileRef.current = replacement; + setActiveProfile(replacement); + if (replacement) { + setProfileName(replacement.name); + setSourcePath(replacement.sourcePath); + setTargetPath(replacement.targetPath); + inspectSavedProfile(replacement); + } else { + profileInspectionRef.current += 1; + setProfileName(""); + setSourcePath(""); + setTargetPath(""); + setPlan(undefined); + setResult(undefined); + setRebind(noRebind); + setStep("choose"); + } + }; + + useEffect(() => { + if (initialProfile) return; + let live = true; + void gateway.listProfiles().then((loaded) => { + if (live) reconcileProfiles(loaded); + }).catch(() => { + // A first-run database failure is surfaced when the user saves; choosing folders still works. + }); + return () => { live = false; }; + }, [gateway, initialProfile]); + + useEffect(() => { + if (initialProfile) inspectSavedProfile(initialProfile); + }, [gateway, initialProfile]); + + useEffect(() => { + if (!auth) return; + let live = true; + let dataVersion = auth.snapshot().dataVersion; + const unsubscribe = auth.subscribe((snapshot) => { + if (!live || snapshot.dataVersion === dataVersion) return; + dataVersion = snapshot.dataVersion; + void gateway.listProfiles().then((loaded) => { + if (live) reconcileProfiles(loaded); + }).catch(() => setError("Local profiles changed, but Rootline could not refresh the list.")); + }); + return () => { live = false; unsubscribe(); }; + }, [auth, gateway]); + + useEffect(() => { + if (step === "choose" && returnToScanRef.current) { + returnToScanRef.current = false; + scanButtonRef.current?.focus(); + } else { + headingRef.current?.focus(); + } + }, [step]); + + useEffect(() => { + document.documentElement.lang = locale; + }, [locale]); + + useEffect(() => { + if (deleteCandidate) deleteCancelRef.current?.focus(); + }, [deleteCandidate]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || (step !== "review" && step !== "result")) return; + event.preventDefault(); + returnToScanRef.current = true; + setStep("choose"); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [step]); + + const choose = async (role: "source" | "target"): Promise => { + const path = await gateway.chooseFolder({ role }); + if (!path) return; + profileInspectionRef.current += 1; + if (role === "source") setSourcePath(path); + else setTargetPath(path); + setRebind((current) => ({ ...current, [role]: false })); + setError(undefined); + setStep("choose"); + }; + + const makeRequest = (id: string): ScanRequest => ({ + operationId: id, + sourcePath, + targetPath, + exclusions: activeProfile?.exclusions ?? defaultExclusions, + }); + + const scan = async (): Promise => { + const id = operationId("scan"); + profileInspectionRef.current += 1; + setError(undefined); + setRebind(noRebind); + setActiveOperation(id); + setStep("scanning"); + try { + const nextPlan = await gateway.scan(makeRequest(id)); + setPlan(nextPlan); + setSelected(new Set(nextPlan.missing)); + setStep("review"); + } catch (unknownError) { + const failure = nativeFailure(unknownError); + if (failure.code === "CANCELLED") { + returnToScanRef.current = true; + setStep("choose"); + } else if (failure.code === "SOURCE_NOT_FOUND") { + setRebind((current) => ({ ...current, source: true })); + setError(text.sourceMissing); + setStep("choose"); + } else if (failure.code === "TARGET_NOT_FOUND") { + setRebind((current) => ({ ...current, target: true })); + setError(text.targetMissing); + setStep("choose"); + } else { + setError(text.genericError); + setStep("choose"); + } + } finally { + setActiveOperation(undefined); + } + }; + + const apply = async (): Promise => { + if (!plan) return; + const id = operationId("apply"); + setActiveOperation(id); + setError(undefined); + setStep("applying"); + try { + const nextResult = await gateway.apply({ + request: makeRequest(id), + plan: { ...plan, operationId: id }, + selected: [...selected], + ...(activeProfile ? { profileId: activeProfile.id } : {}), + }); + setResult(nextResult); + setStep("result"); + } catch (unknownError) { + const failure = nativeFailure(unknownError); + setError( + failure.code === "STALE_PLAN" ? text.stalePlan + : failure.code === "SOURCE_NOT_FOUND" ? text.sourceMissing + : failure.code === "TARGET_NOT_FOUND" ? text.targetMissing + : failure.code === "CANCELLED" ? text.operationCancelled + : text.genericError, + ); + setStep("review"); + } finally { + setActiveOperation(undefined); + } + }; + + const cancel = async (): Promise => { + if (activeOperation) await gateway.cancel(activeOperation); + }; + + const selectProfile = (profile: Profile): void => { + activeProfileRef.current = profile; + setActiveProfile(profile); + setProfileName(profile.name); + setSourcePath(profile.sourcePath); + setTargetPath(profile.targetPath); + setPlan(undefined); + setResult(undefined); + setError(undefined); + setStep("choose"); + inspectSavedProfile(profile); + }; + + const requestProfileDelete = (profile: Profile, returnFocus: HTMLElement): void => { + deleteReturnFocusRef.current = returnFocus; + setDeleteCandidate(profile); + }; + + const closeProfileDelete = (): void => { + setDeleteCandidate(undefined); + queueMicrotask(() => deleteReturnFocusRef.current?.focus()); + }; + + const onDeleteDialogKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + closeProfileDelete(); + return; + } + if (event.key !== "Tab") return; + const buttons = [...(deleteDialogRef.current?.querySelectorAll("button:not(:disabled)") ?? [])]; + if (buttons.length === 0) return; + const first = buttons[0]!; + const last = buttons[buttons.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const onProfileKeyDown = (event: React.KeyboardEvent, index: number): void => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const delta = event.key === "ArrowDown" ? 1 : -1; + const next = (index + delta + profiles.length) % profiles.length; + document.querySelector(`[data-profile-index="${next}"]`)?.focus(); + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectProfile(profiles[index]!); + } else if (event.key === "Delete") { + event.preventDefault(); + requestProfileDelete(profiles[index]!, event.currentTarget); + } + }; + + const newProfile = (): void => { + profileInspectionRef.current += 1; + activeProfileRef.current = undefined; + setActiveProfile(undefined); + setProfileName(""); + setSourcePath(""); + setTargetPath(""); + setPlan(undefined); + setResult(undefined); + setRebind(noRebind); + setStep("choose"); + }; + + const saveProfile = async (): Promise => { + const now = new Date().toISOString(); + const profile: Profile = { + id: activeProfile?.id ?? crypto.randomUUID(), + name: profileName.trim(), + sourcePath, + targetPath, + exclusions: activeProfile?.exclusions ?? defaultExclusions, + createdAt: activeProfile?.createdAt ?? now, + updatedAt: now, + }; + const issue = validateSyncProfile(profile)[0]; + if (issue) { + setError(issue.field === "name" ? text.profileNameInvalid : text.profileInvalid); + return; + } + setError(undefined); + try { + const saved = await gateway.saveProfile(profile); + setProfiles((current) => [saved, ...current.filter((entry) => entry.id !== saved.id)]); + setActiveProfile(saved); + activeProfileRef.current = saved; + setProfileName(saved.name); + syncCoordinator?.profileEdited(); + } catch (unknownError) { + const failure = nativeFailure(unknownError); + setError(failure.code === "VALIDATION_FAILED" ? text.profileInvalid : text.genericError); + } + }; + + const deleteProfile = async (): Promise => { + if (!deleteCandidate) return; + const deletedId = deleteCandidate.id; + try { + await gateway.deleteProfile(deletedId); + setProfiles((current) => current.filter((profile) => profile.id !== deletedId)); + if (activeProfile?.id === deletedId) { + activeProfileRef.current = undefined; + setActiveProfile(undefined); + setProfileName(""); + setSourcePath(""); + setTargetPath(""); + setPlan(undefined); + setResult(undefined); + profileInspectionRef.current += 1; + setRebind(noRebind); + setStep("choose"); + } + setDeleteCandidate(undefined); + setError(undefined); + syncCoordinator?.profileEdited(); + queueMicrotask(() => newProfileRef.current?.focus()); + } catch { + setDeleteCandidate(undefined); + setError(text.genericError); + queueMicrotask(() => deleteReturnFocusRef.current?.focus()); + } + }; + + const createdCount = result?.directories.filter((entry) => entry.status === "created").length ?? 0; + const currentStep = step === "choose" ? 0 : step === "scanning" ? 1 : step === "review" ? 2 : 3; + + return ( +
+ + +
+
+ +
+ {auth ? : null} + +
+
+ +
+ {step === "choose" ? ( +
+

{text.chooseEyebrow}

+

{text.chooseTitle}

+

{text.chooseBody}

+ {error ?

{error}

: null} +
+
+ 01
{text.source}{sourcePath || text.notChosen}
+ +
+ +
+ 02
{text.target}{targetPath || text.notChosen}
+ +
+
+
+ + setProfileName(event.currentTarget.value)} + /> + +
+ +
+ ) : null} + + {step === "scanning" || step === "applying" ? ( +
+
+ ) : null} + + {step === "review" && plan ? ( +
+

{text.reviewEyebrow(plan.targetCaseSensitive)}

+

{text.review(plan.missing.length)}

+

{text.reviewBody}

+ {error ?

{error}

: null} + {plan.skippedLinks.length ?

{text.skipped(plan.skippedLinks.length)}

: null} + {plan.missing.length === 0 ? ( +

{text.empty}

{text.emptyBody}

+ ) : null} + {plan.diffEntries.length ? ( + + ) : null} +
+ + {plan.missing.length ? : null} +
+
+ ) : null} + + {step === "result" && result ? ( +
+ +

{text.resultEyebrow}

+

{result.cancelled ? text.cancelledResult(createdCount) : text.result(createdCount)}

+

{result.cancelled ? text.cancelledBody : text.resultBody}

+
+
{createdCount}{text.created}
+
{result.directories.filter((entry) => entry.status === "already-exists").length}{text.alreadyExists}
+
{result.directories.filter((entry) => entry.status === "failed").length}{text.failed}
+
+
    + {result.directories.map((entry) => ( +
  • + {entry.relativePath} + {entry.status === "created" ? text.created : entry.status === "already-exists" ? text.alreadyExists : text.failed} + {entry.error ? {entry.error} : null} +
  • + ))} +
+ {result.directories.some((entry) => entry.status === "failed") ?

{text.failureAction}

: null} +
+ + +
+
+ ) : null} +
+
+ {deleteCandidate ? ( +
+
+

{text.deleteProfileTitle(deleteCandidate.name)}

+

{text.deleteProfileBody}

+
+ + +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/auth.ts b/apps/desktop/src/auth.ts new file mode 100644 index 0000000..13fe782 --- /dev/null +++ b/apps/desktop/src/auth.ts @@ -0,0 +1,505 @@ +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import { appDataDir, join } from "@tauri-apps/api/path"; +import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Stronghold, type Store } from "@tauri-apps/plugin-stronghold"; +import { + UserManager, + WebStorageStateStore, + type AsyncStorage, + type INavigator, + type IWindow, + type NavigateParams, + type NavigateResponse, + type UserManagerSettings, +} from "oidc-client-ts"; + +export const OIDC_SCOPE = "openid profile email permissions offline_access"; +export const OIDC_REDIRECT_URI = "rootline://auth/callback"; +export const OIDC_BROWSER_FLOW_TIMEOUT_MS = 5 * 60 * 1000; + +export interface OidcConfiguration { + authority: string; + clientId: string; + apiUrl: string; + redirectUri: typeof OIDC_REDIRECT_URI; + scope: typeof OIDC_SCOPE; +} + +export interface AuthViewUser { + sub: string; + name?: string; + email?: string; + permissions: string[]; +} + +export interface AuthSnapshot { + configured: boolean; + loading: boolean; + user: AuthViewUser | null; + dataVersion: number; + accountClaimRequired?: boolean; + epochResetRequired?: boolean; + epochResetPreservesConsentedOutbox?: boolean; + signInPending?: boolean; + quarantinedMutations?: number; + error?: string; +} + +export interface AuthController { + snapshot(): AuthSnapshot; + subscribe(listener: (snapshot: AuthSnapshot) => void): () => void; + initialize(): Promise; + signIn(): Promise; + cancelSignIn(): Promise; + handleCallback(url: string): Promise; + signOut(removeLocalProfiles: boolean): Promise; + deleteAccountData(removeLocalProfiles: boolean): Promise; + resolveEpochReset(removeLocalProfiles: boolean): Promise; + resolveAccountClaim(uploadExisting: boolean): Promise; + sync(): Promise; + dispose(): void; +} + +function withoutAuthError(snapshot: AuthSnapshot): AuthSnapshot { + const next = { ...snapshot }; + delete next.error; + return next; +} + +interface Environment { + VITE_AUTHENTIK_ISSUER?: string; + VITE_AUTHENTIK_CLIENT_ID?: string; + VITE_ROOTLINE_SYNC_API?: string; +} + +export function readOidcConfiguration(environment: Environment): OidcConfiguration | null { + const values = [environment.VITE_AUTHENTIK_ISSUER, environment.VITE_AUTHENTIK_CLIENT_ID, environment.VITE_ROOTLINE_SYNC_API]; + if (values.every((value) => !value)) return null; + if (!environment.VITE_AUTHENTIK_ISSUER) throw new Error("VITE_AUTHENTIK_ISSUER is required when hosted sync is configured."); + if (!environment.VITE_AUTHENTIK_CLIENT_ID) throw new Error("VITE_AUTHENTIK_CLIENT_ID is required when hosted sync is configured."); + if (!environment.VITE_ROOTLINE_SYNC_API) throw new Error("VITE_ROOTLINE_SYNC_API is required when hosted sync is configured."); + const authority = new URL(environment.VITE_AUTHENTIK_ISSUER); + const api = new URL(environment.VITE_ROOTLINE_SYNC_API); + if (authority.protocol !== "https:" || api.protocol !== "https:") throw new Error("Hosted authentication and sync endpoints must use HTTPS."); + return { authority: authority.toString(), clientId: environment.VITE_AUTHENTIK_CLIENT_ID, apiUrl: api.toString().replace(/\/$/, ""), redirectUri: OIDC_REDIRECT_URI, scope: OIDC_SCOPE }; +} + +function callbackError(): Error { + return new Error("AUTH_CALLBACK_INVALID: Rootline rejected an unexpected authentication callback."); +} + +export function validateCallbackUrl(rawUrl: string, expectedState?: string): URL { + let url: URL; + try { url = new URL(rawUrl); } catch { throw callbackError(); } + if (url.protocol !== "rootline:" || url.hostname !== "auth" || url.pathname !== "/callback" || url.hash) throw callbackError(); + if (url.searchParams.getAll("code").length !== 1 || url.searchParams.getAll("state").length !== 1) throw callbackError(); + if (!url.searchParams.get("code") || !url.searchParams.get("state")) throw callbackError(); + if (expectedState !== undefined && url.searchParams.get("state") !== expectedState) throw callbackError(); + return url; +} + +interface ProjectableUser { + profile: { sub?: unknown; name?: unknown; email?: unknown; permissions?: unknown }; + expired?: boolean | undefined; + access_token?: unknown; + id_token?: unknown; + refresh_token?: unknown; +} + +export function projectUser(user: ProjectableUser): AuthViewUser | null { + if (user.expired || typeof user.profile.sub !== "string") return null; + const permissions = Array.isArray(user.profile.permissions) + ? user.profile.permissions.filter((value): value is string => typeof value === "string") + : []; + return { + sub: user.profile.sub, + ...(typeof user.profile.name === "string" ? { name: user.profile.name } : {}), + ...(typeof user.profile.email === "string" ? { email: user.profile.email } : {}), + permissions, + }; +} + +export class StrongholdAsyncStorage implements AsyncStorage { + private readonly encoder = new TextEncoder(); + private readonly decoder = new TextDecoder(); + private readonly opened = this.open(); + private static readonly INDEX = "__rootline_oidc_keys"; + + get length(): Promise { return this.keys().then((keys) => keys.length); } + private async open(): Promise<{ stronghold: Stronghold; store: Store }> { + const password = await invoke("auth_vault_password"); + const stronghold = await Stronghold.load(await join(await appDataDir(), "rootline-auth.stronghold"), password); + let client; + try { client = await stronghold.loadClient("rootline-oidc"); } + catch { client = await stronghold.createClient("rootline-oidc"); } + return { stronghold, store: client.getStore() }; + } + async clear(): Promise { + const { stronghold, store } = await this.opened; + for (const key of await this.keys()) await store.remove(key); + await store.remove(StrongholdAsyncStorage.INDEX); + await stronghold.save(); + } + async getItem(key: string): Promise { + const value = await (await this.opened).store.get(key); + return value ? this.decoder.decode(value) : null; + } + key(index: number): Promise { return this.keys().then((keys) => keys[index] ?? null); } + async removeItem(key: string): Promise { + const { stronghold, store } = await this.opened; + await store.remove(key); + await this.writeKeys((await this.keys()).filter((value) => value !== key)); + await stronghold.save(); + } + async setItem(key: string, value: string): Promise { + const { stronghold, store } = await this.opened; + await store.insert(key, Array.from(this.encoder.encode(value))); + const keys = await this.keys(); + if (!keys.includes(key)) await this.writeKeys([...keys, key].sort()); + await stronghold.save(); + } + private async writeKeys(keys: string[]): Promise { + await (await this.opened).store.insert(StrongholdAsyncStorage.INDEX, Array.from(this.encoder.encode(JSON.stringify(keys)))); + } + private async keys(): Promise { + const value = await (await this.opened).store.get(StrongholdAsyncStorage.INDEX); + if (!value) return []; + const parsed: unknown = JSON.parse(this.decoder.decode(value)); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; + } +} + +class SystemBrowserNavigator implements INavigator { + prepare(): Promise { + return Promise.resolve({ + navigate: async ({ url }: NavigateParams): Promise => { await openUrl(url); return { url }; }, + close: () => undefined, + }); + } + callback(): Promise { return Promise.resolve(); } +} + +export function createOidcSettings( + config: OidcConfiguration, + stateStore: WebStorageStateStore, + userStore: WebStorageStateStore, +): UserManagerSettings { + return { + authority: config.authority, + client_id: config.clientId, + redirect_uri: config.redirectUri, + post_logout_redirect_uri: config.redirectUri, + response_type: "code", + disablePKCE: false, + scope: config.scope, + stateStore, + userStore, + automaticSilentRenew: true, + revokeTokensOnSignout: true, + loadUserInfo: true, + }; +} + +export class DesktopAuthController implements AuthController { + private current: AuthSnapshot = { configured: true, loading: true, user: null, dataVersion: 0 }; + private readonly listeners = new Set<(snapshot: AuthSnapshot) => void>(); + private readonly manager: UserManager; + private unlisteners: UnlistenFn[] = []; + private resetEpoch: string | undefined; + private initialization = 0; + private initialized = false; + private initializationFlight: { generation: number; promise: Promise } | undefined; + private callbackQueue: Promise = Promise.resolve(); + private readonly processedCallbackStates = new Set(); + private signInTimer: ReturnType | undefined; + private signInGeneration = 0; + private signInStateCleanup: Promise = Promise.resolve(); + + constructor(private readonly config: OidcConfiguration, manager?: UserManager) { + if (manager) { + this.manager = manager; + } else { + const storage = new StrongholdAsyncStorage(); + const stateStore = new WebStorageStateStore({ prefix: "rootline.oidc.state.", store: storage }); + const userStore = new WebStorageStateStore({ prefix: "rootline.oidc.user.", store: storage }); + const settings = createOidcSettings(config, stateStore, userStore); + this.manager = new UserManager(settings, new SystemBrowserNavigator()); + } + } + + snapshot(): AuthSnapshot { return this.current; } + subscribe(listener: (snapshot: AuthSnapshot) => void): () => void { + this.listeners.add(listener); + listener(this.current); + return () => this.listeners.delete(listener); + } + private update(value: AuthSnapshot): void { this.current = value; this.listeners.forEach((listener) => listener(value)); } + + initialize(): Promise { + if (this.initialized) return Promise.resolve(); + if (this.initializationFlight?.generation === this.initialization) return this.initializationFlight.promise; + const generation = ++this.initialization; + const promise = this.initializeOnce(generation).catch((error: unknown) => { + if (generation === this.initialization) { + this.update({ + ...this.current, + loading: false, + error: error instanceof Error ? error.message : "Secure account storage is unavailable.", + }); + } + throw error; + }).finally(() => { + if (this.initializationFlight?.generation === generation) this.initializationFlight = undefined; + }); + this.initializationFlight = { generation, promise }; + return promise; + } + + private async initializeOnce(generation: number): Promise { + let deepLink: UnlistenFn | undefined; + try { + deepLink = await onOpenUrl((urls) => { for (const url of urls) void this.handleCallback(url); }); + if (generation !== this.initialization) return; + for (const url of await getCurrent() ?? []) await this.handleCallback(url); + if (generation !== this.initialization) return; + const user = await this.manager.getUser(); + if (generation !== this.initialization) return; + this.update({ configured: true, loading: false, user: user ? projectUser(user) : this.current.user, dataVersion: this.current.dataVersion }); + this.unlisteners.push(deepLink); + deepLink = undefined; + this.initialized = true; + } finally { + deepLink?.(); + } + } + + async signIn(): Promise { + if (this.current.signInPending) await this.cancelSignIn(); + this.clearSignInTimer(); + const generation = ++this.signInGeneration; + this.update({ ...withoutAuthError(this.current), loading: true, signInPending: false }); + try { + await this.signInStateCleanup; + if (generation !== this.signInGeneration) return; + await this.manager.signinRedirect({ nonce: crypto.randomUUID() }); + if (generation !== this.signInGeneration) return; + this.update({ ...withoutAuthError(this.current), loading: false, signInPending: true }); + this.signInTimer = setTimeout(() => { + if (generation !== this.signInGeneration) return; + this.signInGeneration += 1; + this.signInTimer = undefined; + this.update({ ...this.current, loading: false, signInPending: false, error: "AUTH_SIGNIN_TIMEOUT" }); + void this.queueSignInStateDiscard(); + }, OIDC_BROWSER_FLOW_TIMEOUT_MS); + } catch (error) { + if (generation === this.signInGeneration) { + this.update({ + ...this.current, + loading: false, + signInPending: false, + error: error instanceof Error ? error.message : "The system browser could not be opened.", + }); + } + throw error; + } + } + + private clearSignInTimer(): void { + if (this.signInTimer) clearTimeout(this.signInTimer); + this.signInTimer = undefined; + } + + private async discardSignInState(): Promise { + try { + const keys = await this.manager.settings.stateStore.getAllKeys(); + await Promise.all(keys.map((key) => this.manager.settings.stateStore.remove(key))); + } catch { + try { await this.manager.clearStaleState(); } catch { /* best-effort protocol-state cleanup */ } + } + } + + private queueSignInStateDiscard(): Promise { + this.signInStateCleanup = this.signInStateCleanup.then(() => this.discardSignInState()); + return this.signInStateCleanup; + } + + async cancelSignIn(): Promise { + this.signInGeneration += 1; + this.clearSignInTimer(); + this.update({ ...withoutAuthError(this.current), loading: false, signInPending: false }); + await this.queueSignInStateDiscard(); + } + + handleCallback(rawUrl: string): Promise { + const pending = this.callbackQueue.then(() => this.processCallback(rawUrl)); + this.callbackQueue = pending.catch(() => undefined); + return pending; + } + + private async processCallback(rawUrl: string): Promise { + const lifecycleGeneration = this.signInGeneration; + try { + const url = validateCallbackUrl(rawUrl); + const state = url.searchParams.get("state")!; + if (this.processedCallbackStates.has(state)) return; + this.processedCallbackStates.add(state); + const user = await this.manager.signinRedirectCallback(url.toString()); + if (lifecycleGeneration !== this.signInGeneration) { + try { await this.manager.removeUser(); } catch { /* stale callbacks must never restore a session */ } + return; + } + this.signInGeneration += 1; + this.clearSignInTimer(); + this.update({ configured: true, loading: false, signInPending: false, user: projectUser(user), dataVersion: this.current.dataVersion }); + try { await this.sync(); } catch { /* sync() already surfaces reset-required; offline sign-in remains valid */ } + } catch (error) { + if (lifecycleGeneration !== this.signInGeneration) return; + this.update({ + configured: true, + loading: false, + signInPending: false, + user: this.current.user, + dataVersion: this.current.dataVersion, + error: error instanceof Error ? error.message : "Authentication failed.", + }); + } + } + + async signOut(removeLocalProfiles: boolean): Promise { + this.signInGeneration += 1; + this.clearSignInTimer(); + try { await this.manager.revokeTokens(["access_token", "refresh_token"]); } catch { /* local sign-out must still complete offline */ } + await this.manager.removeUser(); + await invoke("disconnect_hosted_account", { removeLocalProfiles }); + this.resetEpoch = undefined; + this.update({ configured: true, loading: false, user: null, dataVersion: this.current.dataVersion + 1 }); + } + + async deleteAccountData(removeLocalProfiles: boolean): Promise { + const user = await this.manager.getUser(); + if (!user || user.expired || !user.access_token || typeof user.profile.sub !== "string") throw new Error("AUTH_REQUIRED"); + await invoke("delete_hosted_account_data", { + apiUrl: this.config.apiUrl, + accessToken: user.access_token, + subject: user.profile.sub, + removeLocalProfiles, + }); + const next = { ...this.current, dataVersion: this.current.dataVersion + 1 }; + if (removeLocalProfiles) delete next.quarantinedMutations; + this.update(next); + } + + async resolveEpochReset(removeLocalProfiles: boolean): Promise { + const user = await this.manager.getUser(); + if (!user || user.expired || typeof user.profile.sub !== "string" || !this.resetEpoch) throw new Error("AUTH_REQUIRED"); + await invoke("accept_hosted_epoch", { + subject: user.profile.sub, + epoch: this.resetEpoch, + removeLocalProfiles, + }); + this.resetEpoch = undefined; + this.update({ + configured: true, + loading: false, + user: projectUser(user), + dataVersion: this.current.dataVersion + 1, + }); + } + + async resolveAccountClaim(uploadExisting: boolean): Promise { + const user = await this.manager.getUser(); + if (!user || user.expired || typeof user.profile.sub !== "string") throw new Error("AUTH_REQUIRED"); + await invoke("claim_hosted_account", { subject: user.profile.sub, uploadExisting }); + this.update({ + configured: true, + loading: false, + user: projectUser(user), + dataVersion: this.current.dataVersion + 1, + }); + void this.sync().catch(() => undefined); + } + + async sync(): Promise { + const user = await this.manager.getUser(); + if (!user || user.expired || !user.access_token || typeof user.profile.sub !== "string") return; + try { + const outcome = await invoke<{ quarantinedMutations?: unknown }>("sync_hosted_profiles", { apiUrl: this.config.apiUrl, accessToken: user.access_token, subject: user.profile.sub }); + const quarantinedMutations = typeof outcome?.quarantinedMutations === "number" && outcome.quarantinedMutations > 0 + ? outcome.quarantinedMutations + : undefined; + const next: AuthSnapshot = { ...this.current, dataVersion: this.current.dataVersion + 1 }; + if (quarantinedMutations === undefined) delete next.quarantinedMutations; + else next.quarantinedMutations = quarantinedMutations; + this.update(next); + } catch (error) { + const value = error as { code?: unknown; message?: unknown; details?: { epoch?: unknown; preservesConsentedOutbox?: unknown } }; + if ((value?.code === "RESET_REQUIRED" || value?.code === "SYNC_EPOCH_RESET_REQUIRED") && typeof value.details?.epoch === "string") { + this.resetEpoch = value.details.epoch; + this.update({ + ...this.current, + loading: false, + epochResetRequired: true, + epochResetPreservesConsentedOutbox: value.details.preservesConsentedOutbox === true, + error: typeof value.message === "string" ? value.message : "Hosted profile data was reset.", + }); + } else if (value?.code === "SYNC_ACCOUNT_CLAIM_REQUIRED") { + this.update({ + ...this.current, + loading: false, + accountClaimRequired: true, + error: "Choose whether this account may upload existing local profiles.", + }); + } + throw error; + } + } + + dispose(): void { + this.signInGeneration += 1; + this.clearSignInTimer(); + this.initialization += 1; + this.initialized = false; + this.initializationFlight = undefined; + this.unlisteners.splice(0).forEach((unlisten) => unlisten()); + } +} + +class LocalAuthController implements AuthController { + private readonly value: AuthSnapshot = { configured: false, loading: false, user: null, dataVersion: 0 }; + snapshot() { return this.value; } + subscribe(listener: (snapshot: AuthSnapshot) => void) { listener(this.value); return () => undefined; } + initialize() { return Promise.resolve(); } + signIn() { return Promise.resolve(); } + cancelSignIn() { return Promise.resolve(); } + handleCallback() { return Promise.resolve(); } + signOut(removeLocalProfiles: boolean) { return removeLocalProfiles ? invoke("clear_local_synced_data") : Promise.resolve(); } + deleteAccountData(removeLocalProfiles: boolean) { return removeLocalProfiles ? invoke("clear_local_synced_data") : Promise.resolve(); } + resolveEpochReset(removeLocalProfiles: boolean) { return removeLocalProfiles ? invoke("clear_local_synced_data") : Promise.resolve(); } + resolveAccountClaim() { return Promise.resolve(); } + sync() { return Promise.resolve(); } + dispose() { /* nothing to release */ } +} + +export function createDesktopAuth(environment: Environment = { + VITE_AUTHENTIK_ISSUER: import.meta.env.VITE_AUTHENTIK_ISSUER, + VITE_AUTHENTIK_CLIENT_ID: import.meta.env.VITE_AUTHENTIK_CLIENT_ID, + VITE_ROOTLINE_SYNC_API: import.meta.env.VITE_ROOTLINE_SYNC_API, +}): AuthController { + const config = readOidcConfiguration(environment); + return config ? new DesktopAuthController(config) : new LocalAuthController(); +} + +export class ProfileSyncCoordinator { + private timer: ReturnType | undefined; + constructor(private readonly auth: AuthController, private readonly debounceMs = 750) {} + private async safeSync(): Promise { try { await this.auth.sync(); } catch { /* offline use remains unaffected */ } } + start(): Promise { return this.safeSync(); } + signedIn(): Promise { return this.safeSync(); } + manual(): Promise { return this.auth.sync(); } + profileEdited(): void { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { this.timer = undefined; void this.safeSync(); }, this.debounceMs); + } +} diff --git a/apps/desktop/src/components/AuthControls.tsx b/apps/desktop/src/components/AuthControls.tsx new file mode 100644 index 0000000..ee13e86 --- /dev/null +++ b/apps/desktop/src/components/AuthControls.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from "react"; + +import type { AuthController, AuthSnapshot, ProfileSyncCoordinator } from "../auth"; +import { authCopy, type Locale } from "../i18n"; + +type Choice = "signout" | "delete" | "reset" | "claim"; + +interface AuthControlsProps { + auth: AuthController; + coordinator?: ProfileSyncCoordinator; + locale?: Locale; +} + +export function AuthControls({ auth, coordinator, locale = "en" }: AuthControlsProps): React.JSX.Element | null { + const text = authCopy[locale]; + const [snapshot, setSnapshot] = useState(() => auth.snapshot()); + const [choice, setChoice] = useState(null); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(); + const [actionStatus, setActionStatus] = useState(); + const dialogRef = useRef(null); + const dialogCancelRef = useRef(null); + const returnFocusRef = useRef(null); + + useEffect(() => { + const unsubscribe = auth.subscribe(setSnapshot); + void auth.initialize().then(() => coordinator?.start()).catch(() => undefined); + return () => { unsubscribe(); auth.dispose(); }; + }, [auth, coordinator]); + + useEffect(() => { + if (choice) dialogCancelRef.current?.focus(); + }, [choice]); + + const closeChoice = (restoreFocus = true): void => { + setChoice(null); + if (restoreFocus) queueMicrotask(() => returnFocusRef.current?.focus()); + }; + + const openChoice = (next: Choice, trigger: HTMLButtonElement): void => { + returnFocusRef.current = trigger; + setChoice(next); + }; + + const onDialogKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "Escape" && !busy) { + event.preventDefault(); + closeChoice(); + return; + } + if (event.key !== "Tab") return; + const buttons = [...(dialogRef.current?.querySelectorAll("button:not(:disabled)") ?? [])]; + if (buttons.length === 0) return; + const first = buttons[0]!; + const last = buttons[buttons.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const run = async (action: () => Promise, announceCompletion = true): Promise => { + setBusy(true); + setActionError(undefined); + setActionStatus(undefined); + try { + await action(); + if (choice) closeChoice(); + if (announceCompletion) setActionStatus(text.actionCompleted); + } catch { + setActionError(text.authFailed); + } finally { + setBusy(false); + } + }; + + const snapshotError = snapshot.error + ? snapshot.error === "AUTH_SIGNIN_TIMEOUT" ? text.signInTimeout : text.authFailed + : undefined; + + if (!snapshot.configured) return null; + if (snapshot.loading) return {text.connecting}; + if (!snapshot.user) { + return ( +
+ {actionError ?? snapshotError ? {actionError ?? snapshotError} : null} + {snapshot.signInPending ? ( + <> + {text.pending} + + + + ) : ( + + )} +
+ ); + } + + const dialogBody = choice === "signout" ? text.dialogBody.signout + : choice === "delete" ? text.dialogBody.delete + : choice === "reset" ? snapshot.epochResetPreservesConsentedOutbox ? text.dialogBody.resetPreserved : text.dialogBody.reset + : text.dialogBody.claim; + + return ( +
+ {snapshot.user.name ?? snapshot.user.email ?? text.signedIn} + {snapshot.accountClaimRequired ? <>{text.accountClaimAlert} : null} + {snapshot.epochResetRequired ? <>{text.epochResetAlert} : null} + {actionError ?? snapshotError ? {actionError ?? snapshotError} : null} + {snapshot.quarantinedMutations ? {text.quarantine(snapshot.quarantinedMutations)} : null} + {busy || actionStatus ? {busy ? text.working : actionStatus} : null} + + + + {choice ? ( +
+

{dialogBody}

+ + + +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/components/DiffTree.tsx b/apps/desktop/src/components/DiffTree.tsx new file mode 100644 index 0000000..35f5954 --- /dev/null +++ b/apps/desktop/src/components/DiffTree.tsx @@ -0,0 +1,227 @@ +import { memo, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; + +import { copy } from "../i18n"; +import type { DiffEntry, DiffStatus } from "../native"; + +export interface DiffTreeLabels { + search: string; + all: string; + selected: string; + clear: string; + selectAllMissing: string; + expandAll: string; + collapseAll: string; + folderDifferences: string; + folderFilter: string; + status: Record; + visibleCount: (count: number) => string; + selectedCount: (count: number) => string; +} + +interface DiffTreeProps { + entries: readonly DiffEntry[]; + selected: ReadonlySet; + onSelectionChange: (selection: Set) => void; + labels?: DiffTreeLabels; +} + +const ROW_HEIGHT = 38; +const VIEWPORT_HEIGHT = 456; +const OVERSCAN = 8; + +function depthOf(path: string): number { + return path.split("/").length; +} + +function parentPaths(entries: readonly string[]): Set { + const parents = new Set(); + const entrySet = new Set(entries); + for (const entry of entries) { + const parts = entry.split("/"); + for (let depth = 1; depth < parts.length; depth += 1) { + const parent = parts.slice(0, depth).join("/"); + if (entrySet.has(parent)) parents.add(parent); + } + } + return parents; +} + +function visibleUnderExpansion(entry: string, expanded: ReadonlySet, parents: ReadonlySet): boolean { + const parts = entry.split("/"); + for (let depth = 1; depth < parts.length; depth += 1) { + const parent = parts.slice(0, depth).join("/"); + if (parents.has(parent) && !expanded.has(parent)) return false; + } + return true; +} + +function closeOverParents(entries: readonly string[], candidates: ReadonlySet): Set { + const entrySet = new Set(entries); + const closed = new Set(); + for (const candidate of candidates) { + if (!entrySet.has(candidate)) continue; + const parts = candidate.split("/"); + for (let depth = 1; depth <= parts.length; depth += 1) { + const path = parts.slice(0, depth).join("/"); + if (entrySet.has(path)) closed.add(path); + } + } + return closed; +} + +export const DiffTree = memo(function DiffTree({ entries, selected, onSelectionChange, labels = copy.en.tree }: DiffTreeProps) { + const paths = useMemo(() => entries.map((entry) => entry.relativePath), [entries]); + const entryByPath = useMemo(() => new Map(entries.map((entry) => [entry.relativePath, entry])), [entries]); + const missingPaths = useMemo(() => entries.filter((entry) => entry.status === "missing").map((entry) => entry.relativePath), [entries]); + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()); + const [filter, setFilter] = useState<"all" | "selected">("all"); + const parents = useMemo(() => parentPaths(paths), [paths]); + const [expanded, setExpanded] = useState>(() => new Set(parents)); + const [scrollTop, setScrollTop] = useState(0); + const [activeEntry, setActiveEntry] = useState(paths[0] ?? ""); + const viewport = useRef(null); + const itemRefs = useRef(new Map()); + const shouldFocusActive = useRef(false); + + const visible = useMemo(() => paths.filter((entry) => { + if (filter === "selected" && !selected.has(entry)) return false; + if (deferredQuery && !entry.toLocaleLowerCase().includes(deferredQuery)) return false; + return visibleUnderExpansion(entry, expanded, parents); + }), [deferredQuery, expanded, filter, parents, paths, selected]); + + useEffect(() => { + if (!visible.includes(activeEntry)) setActiveEntry(visible[0] ?? ""); + }, [activeEntry, visible]); + + useEffect(() => { + if (!shouldFocusActive.current) return; + shouldFocusActive.current = false; + itemRefs.current.get(activeEntry)?.focus(); + }, [activeEntry, scrollTop]); + + const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const end = Math.min(visible.length, Math.ceil((scrollTop + VIEWPORT_HEIGHT) / ROW_HEIGHT) + OVERSCAN); + const windowed = visible.slice(start, end); + + const focusEntry = (entry: string): void => { + const index = visible.indexOf(entry); + if (index < 0) return; + const nextTop = index * ROW_HEIGHT; + if (nextTop < scrollTop || nextTop + ROW_HEIGHT > scrollTop + VIEWPORT_HEIGHT) { + const top = Math.max(0, nextTop - ROW_HEIGHT); + if (viewport.current) viewport.current.scrollTop = top; + setScrollTop(top); + } + shouldFocusActive.current = true; + setActiveEntry(entry); + }; + + const toggleSelection = (entry: string): void => { + if (entryByPath.get(entry)?.status !== "missing") return; + const descendants = missingPaths.filter((candidate) => candidate === entry || candidate.startsWith(`${entry}/`)); + const next = new Set(selected); + const shouldSelect = descendants.some((candidate) => !next.has(candidate)); + for (const descendant of descendants) { + if (shouldSelect) next.add(descendant); + else next.delete(descendant); + } + onSelectionChange(closeOverParents(missingPaths, next)); + }; + + const toggleExpanded = (entry: string, force?: boolean): void => { + setExpanded((current) => { + const next = new Set(current); + const shouldExpand = force ?? !next.has(entry); + if (shouldExpand) next.add(entry); + else next.delete(entry); + return next; + }); + }; + + const onTreeKeyDown = (event: React.KeyboardEvent, entry: string): void => { + const index = visible.indexOf(entry); + if (index < 0) return; + if (event.key === "ArrowDown") focusEntry(visible[Math.min(visible.length - 1, index + 1)]!); + else if (event.key === "ArrowUp") focusEntry(visible[Math.max(0, index - 1)]!); + else if (event.key === "Home") focusEntry(visible[0]!); + else if (event.key === "End") focusEntry(visible[visible.length - 1]!); + else if (event.key === "ArrowRight" && parents.has(entry)) { + if (!expanded.has(entry)) toggleExpanded(entry, true); + else { + const child = visible.find((candidate) => candidate.startsWith(`${entry}/`) && depthOf(candidate) === depthOf(entry) + 1); + if (child) focusEntry(child); + } + } else if (event.key === "ArrowLeft") { + if (parents.has(entry) && expanded.has(entry)) toggleExpanded(entry, false); + else { + const parts = entry.split("/"); + parts.pop(); + const parent = parts.join("/"); + if (parent) focusEntry(parent); + } + } else if (event.key === " " || event.key === "Enter") toggleSelection(entry); + else return; + event.preventDefault(); + }; + + return ( +
+
+ +
+ + +
+ + + + +
+
+ {labels.visibleCount(visible.length)} + {labels.selectedCount(selected.size)} +
+
setScrollTop(event.currentTarget.scrollTop)}> +
+ {windowed.map((entry, offset) => { + const index = start + offset; + const isParent = parents.has(entry); + const isExpanded = expanded.has(entry); + const isSelected = selected.has(entry); + const diff = entryByPath.get(entry)!; + const statusId = `diff-status-${index}`; + return ( +
{ if (node) itemRefs.current.set(entry, node); else itemRefs.current.delete(entry); }} + className="tree-row" + role="treeitem" + aria-label={entry} + aria-level={depthOf(entry)} + aria-selected={diff.status === "missing" ? isSelected : undefined} + aria-expanded={isParent ? isExpanded : undefined} + aria-describedby={statusId} + tabIndex={activeEntry === entry ? 0 : -1} + key={entry} + onClick={() => { setActiveEntry(entry); if (diff.status === "missing") toggleSelection(entry); else if (isParent) toggleExpanded(entry); }} + onFocus={() => setActiveEntry(entry)} + onKeyDown={(event) => onTreeKeyDown(event, entry)} + style={{ top: `${index * ROW_HEIGHT}px`, paddingInlineStart: `${14 + (depthOf(entry) - 1) * 22}px` }} + > + + {diff.status === "missing" ? :
+ ); + })} +
+
+
+ ); +}); diff --git a/apps/desktop/src/i18n.ts b/apps/desktop/src/i18n.ts new file mode 100644 index 0000000..a18b16d --- /dev/null +++ b/apps/desktop/src/i18n.ts @@ -0,0 +1,237 @@ +export type Locale = "en" | "vi"; + +export const authCopy = { + en: { + connecting: "Connecting account…", + signIn: "Sign in", + pending: "Waiting for sign-in in your browser.", + cancelSignIn: "Cancel sign-in", + retrySignIn: "Try sign-in again", + signedIn: "Signed in", + accountClaimAlert: "Choose whether this account may upload existing local profiles.", + reviewLocal: "Review local profiles", + epochResetAlert: "Hosted data was reset. Review local profiles before reconnecting.", + reviewReset: "Review reset", + authFailed: "Rootline could not complete the account action. Try again.", + signInTimeout: "The browser sign-in timed out. You can safely try again.", + actionCompleted: "Account action completed.", + working: "Working…", + syncNow: "Sync now", + signOut: "Sign out", + deleteHosted: "Delete hosted data", + quarantine: (count: number) => `${count.toLocaleString()} local profile ${count === 1 ? "change" : "changes"} could not be uploaded. Edit and save the affected profiles to retry, or delete profiles whose exclusions cannot be corrected.`, + dialogLabel: { + signout: "Sign out options", + delete: "Delete hosted data options", + reset: "Hosted reset options", + claim: "Local profile upload options", + }, + dialogBody: { + signout: "Choose what Rootline keeps on this device. Removing local profiles does not delete run history.", + delete: "Hosted profiles will be permanently deleted and the sync epoch will rotate. Removing local profiles from this device does not delete run history.", + reset: "Accept the new hosted epoch. Stale queued changes will be discarded and will not be uploaded. Removing local profiles does not delete run history.", + resetPreserved: "Accept the existing hosted epoch. Explicitly consented local profiles remain queued and will be uploaded; other stale cloud-owned changes are never retained. Removing local profiles does not delete run history.", + claim: "These profiles may contain absolute paths. Choose whether to upload existing queued profiles to this account.", + }, + uploadExisting: "Upload existing profiles", + keepLocal: "Keep local profiles", + keepLocalOnly: "Keep local only", + removeLocal: "Remove local profiles", + cancel: "Cancel", + }, + vi: { + connecting: "Đang kết nối tài khoản…", + signIn: "Đăng nhập", + pending: "Đang chờ đăng nhập trong trình duyệt.", + cancelSignIn: "Hủy đăng nhập", + retrySignIn: "Thử đăng nhập lại", + signedIn: "Đã đăng nhập", + accountClaimAlert: "Hãy chọn tài khoản này có được tải lên các hồ sơ cục bộ hiện có hay không.", + reviewLocal: "Xem lại hồ sơ cục bộ", + epochResetAlert: "Dữ liệu lưu trữ đã được đặt lại. Hãy xem lại hồ sơ cục bộ trước khi kết nối lại.", + reviewReset: "Xem lại đặt lại", + authFailed: "Rootline không thể hoàn tất thao tác tài khoản. Hãy thử lại.", + signInTimeout: "Phiên đăng nhập trong trình duyệt đã hết hạn. Bạn có thể thử lại an toàn.", + actionCompleted: "Đã hoàn tất thao tác tài khoản.", + working: "Đang xử lý…", + syncNow: "Đồng bộ ngay", + signOut: "Đăng xuất", + deleteHosted: "Xóa dữ liệu lưu trữ", + quarantine: (count: number) => `${count.toLocaleString()} thay đổi hồ sơ cục bộ không thể tải lên. Hãy chỉnh sửa và lưu các hồ sơ bị ảnh hưởng để thử lại, hoặc xóa hồ sơ có quy tắc loại trừ không thể sửa.`, + dialogLabel: { + signout: "Tùy chọn đăng xuất", + delete: "Tùy chọn xóa dữ liệu lưu trữ", + reset: "Tùy chọn đặt lại dữ liệu lưu trữ", + claim: "Tùy chọn tải lên hồ sơ cục bộ", + }, + dialogBody: { + signout: "Chọn dữ liệu Rootline giữ trên thiết bị này. Khi xóa hồ sơ cục bộ, lịch sử lượt chạy vẫn được giữ lại.", + delete: "Các hồ sơ lưu trữ sẽ bị xóa vĩnh viễn và epoch đồng bộ sẽ thay đổi. Xóa hồ sơ cục bộ khỏi thiết bị không xóa lịch sử lượt chạy.", + reset: "Chấp nhận epoch lưu trữ mới. Các thay đổi cũ đang chờ sẽ bị loại bỏ và không được tải lên. Xóa hồ sơ cục bộ không xóa lịch sử lượt chạy.", + resetPreserved: "Chấp nhận epoch lưu trữ hiện có. Các hồ sơ cục bộ đã được đồng ý rõ ràng vẫn nằm trong hàng đợi và sẽ được tải lên; các thay đổi cũ khác do đám mây sở hữu không được giữ lại. Xóa hồ sơ cục bộ không xóa lịch sử lượt chạy.", + claim: "Các hồ sơ này có thể chứa đường dẫn tuyệt đối. Hãy chọn có tải các hồ sơ hiện đang chờ lên tài khoản này hay không.", + }, + uploadExisting: "Tải lên hồ sơ hiện có", + keepLocal: "Giữ hồ sơ cục bộ", + keepLocalOnly: "Chỉ giữ cục bộ", + removeLocal: "Xóa hồ sơ cục bộ", + cancel: "Hủy", + }, +} as const; + +export const copy = { + en: { + localeButton: "Tiếng Việt", + profiles: "Profiles", + workflowProgress: "Workflow progress", + chooseEyebrow: "Structure / additive", + newProfile: "New profile", + deleteProfile: (name: string) => `Delete profile ${name}`, + deleteProfileTitle: (name: string) => `Delete profile ${name}?`, + deleteProfileBody: "This removes the profile from this device and queues a hosted tombstone when cloud sync is enabled. Run history is preserved.", + confirmProfileDelete: "Confirm profile deletion", + cancelProfileDelete: "Cancel profile deletion", + noProfiles: "No saved profiles", + offline: "Offline workspace", + chooseTitle: "Choose two roots", + chooseBody: "Rootline compares directory structure only. Files stay untouched.", + source: "Source", + target: "Target", + chooseSource: "Choose source folder", + chooseTarget: "Choose target folder", + rebindSource: "Rebind source", + rebindTarget: "Rebind target", + notChosen: "Not chosen", + scan: "Scan differences", + scanning: "Tracing folder structure…", + cancel: "Cancel", + review: (count: number) => `Review ${count.toLocaleString()} missing folders`, + reviewBody: "Only selected missing directories will be created. Files and existing folders are never removed.", + empty: "The target already has this structure.", + emptyBody: "Nothing will be changed. Choose another pair or scan again later.", + apply: "Create selected folders", + applying: "Creating folders…", + result: (count: number) => `${count.toLocaleString()} folders created`, + resultBody: "Rootline finished the additive run. Existing content was left in place.", + created: "created", + unchanged: "unchanged", + failed: "failed", + alreadyExists: "already existed", + cancelledResult: (count: number) => `Cancelled after creating ${count.toLocaleString()} ${count === 1 ? "folder" : "folders"}`, + cancelledBody: "Rootline stopped safely and kept the folders already created.", + failureAction: "Check folder access, then run again.", + reviewEyebrow: (caseSensitive: boolean) => `Difference / ${caseSensitive ? "case-sensitive" : "case-insensitive"}`, + resultEyebrow: "Run / complete", + again: "Run again", + newPair: "Choose another pair", + sourceMissing: "The saved source is no longer available. Choose a new source folder to continue.", + targetMissing: "The saved target is no longer available. Choose a new target folder to continue.", + genericError: "Rootline could not complete this operation. Check folder access and try again.", + stalePlan: "The folders changed after review. Scan again before applying.", + operationCancelled: "The operation was cancelled safely.", + save: "Save profile", + profileName: "Profile name", + profileNameInvalid: "Profile name must contain 1–80 characters.", + profileInvalid: "This profile exceeds Rootline’s limits. Use a 1–80 character name, paths up to 4,096 characters, and at most 100 exclusion patterns of 1–256 characters.", + all: "All", + selected: "Selected", + clear: "Clear selection", + selectAll: "Select all", + search: "Search folders", + expand: "Expand", + collapse: "Collapse", + skipped: (count: number) => `${count.toLocaleString()} linked folders skipped for safety`, + steps: ["Choose", "Scan", "Review", "Apply"], + tree: { + search: "Search folders", + all: "All", + selected: "Selected", + clear: "Clear selection", + selectAllMissing: "Select all missing", + expandAll: "Expand all", + collapseAll: "Collapse all", + folderDifferences: "Folder differences", + folderFilter: "Folder filter", + status: { missing: "Missing", exists: "Exists", excluded: "Excluded", unreadable: "Unreadable" }, + visibleCount: (count: number) => `${count.toLocaleString()} folders`, + selectedCount: (count: number) => `${count.toLocaleString()} selected`, + }, + }, + vi: { + localeButton: "English", + profiles: "Hồ sơ", + workflowProgress: "Tiến trình đồng bộ", + chooseEyebrow: "Cấu trúc / chỉ bổ sung", + newProfile: "Hồ sơ mới", + deleteProfile: (name: string) => `Xóa hồ sơ ${name}`, + deleteProfileTitle: (name: string) => `Xóa hồ sơ ${name}?`, + deleteProfileBody: "Thao tác này xóa hồ sơ khỏi thiết bị và xếp hàng bản ghi xóa khi bật đồng bộ đám mây. Lịch sử lượt chạy vẫn được giữ lại.", + confirmProfileDelete: "Xác nhận xóa hồ sơ", + cancelProfileDelete: "Hủy xóa hồ sơ", + noProfiles: "Chưa có hồ sơ đã lưu", + offline: "Không gian ngoại tuyến", + chooseTitle: "Chọn hai thư mục gốc", + chooseBody: "Rootline chỉ so sánh cấu trúc thư mục. Tệp luôn được giữ nguyên.", + source: "Nguồn", + target: "Đích", + chooseSource: "Chọn thư mục nguồn", + chooseTarget: "Chọn thư mục đích", + rebindSource: "Chọn lại nguồn", + rebindTarget: "Chọn lại đích", + notChosen: "Chưa chọn", + scan: "Quét khác biệt", + scanning: "Đang lần theo cấu trúc thư mục…", + cancel: "Hủy", + review: (count: number) => `Xem lại ${count.toLocaleString()} thư mục còn thiếu`, + reviewBody: "Chỉ các thư mục còn thiếu đã chọn mới được tạo. Tệp và thư mục hiện có không bao giờ bị xóa.", + empty: "Thư mục đích đã có cấu trúc này.", + emptyBody: "Không có gì thay đổi. Hãy chọn cặp khác hoặc quét lại sau.", + apply: "Tạo các thư mục đã chọn", + applying: "Đang tạo thư mục…", + result: (count: number) => `Đã tạo ${count.toLocaleString()} thư mục`, + resultBody: "Rootline đã hoàn tất lượt bổ sung. Nội dung hiện có được giữ nguyên.", + created: "đã tạo", + unchanged: "không đổi", + failed: "thất bại", + alreadyExists: "đã tồn tại", + cancelledResult: (count: number) => `Đã hủy sau khi tạo ${count.toLocaleString()} thư mục`, + cancelledBody: "Rootline đã dừng an toàn và giữ lại các thư mục đã tạo.", + failureAction: "Kiểm tra quyền truy cập rồi chạy lại.", + reviewEyebrow: (caseSensitive: boolean) => `Khác biệt / ${caseSensitive ? "phân biệt hoa thường" : "không phân biệt hoa thường"}`, + resultEyebrow: "Lượt chạy / hoàn tất", + again: "Chạy lại", + newPair: "Chọn cặp khác", + sourceMissing: "Nguồn đã lưu không còn khả dụng. Hãy chọn thư mục nguồn mới để tiếp tục.", + targetMissing: "Đích đã lưu không còn khả dụng. Hãy chọn thư mục đích mới để tiếp tục.", + genericError: "Rootline không thể hoàn tất thao tác. Hãy kiểm tra quyền truy cập thư mục và thử lại.", + stalePlan: "Thư mục đã thay đổi sau khi xem lại. Hãy quét lại trước khi áp dụng.", + operationCancelled: "Thao tác đã được hủy an toàn.", + save: "Lưu hồ sơ", + profileName: "Tên hồ sơ", + profileNameInvalid: "Tên hồ sơ phải có từ 1–80 ký tự.", + profileInvalid: "Hồ sơ vượt quá giới hạn cho phép. Hãy dùng tên dài 1–80 ký tự, đường dẫn tối đa 4.096 ký tự và tối đa 100 mẫu loại trừ dài 1–256 ký tự.", + all: "Tất cả", + selected: "Đã chọn", + clear: "Bỏ chọn", + selectAll: "Chọn tất cả", + search: "Tìm thư mục", + expand: "Mở rộng", + collapse: "Thu gọn", + skipped: (count: number) => `Đã bỏ qua ${count.toLocaleString()} thư mục liên kết để an toàn`, + steps: ["Chọn", "Quét", "Xem lại", "Áp dụng"], + tree: { + search: "Tìm thư mục", + all: "Tất cả", + selected: "Đã chọn", + clear: "Bỏ chọn", + selectAllMissing: "Chọn tất cả thư mục còn thiếu", + expandAll: "Mở rộng tất cả", + collapseAll: "Thu gọn tất cả", + folderDifferences: "Các thư mục khác biệt", + folderFilter: "Bộ lọc thư mục", + status: { missing: "Còn thiếu", exists: "Đã tồn tại", excluded: "Đã loại trừ", unreadable: "Không thể đọc" }, + visibleCount: (count: number) => `${count.toLocaleString()} thư mục`, + selectedCount: (count: number) => `Đã chọn ${count.toLocaleString()}`, + }, + }, +} as const; diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts new file mode 100644 index 0000000..218eab4 --- /dev/null +++ b/apps/desktop/src/index.ts @@ -0,0 +1,3 @@ +export { App } from "./App"; +export { DiffTree } from "./components/DiffTree"; +export type { NativeGateway, Profile, ScanPlan } from "./native"; diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 0000000..2cd780e --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; +import { createDesktopAuth, ProfileSyncCoordinator } from "./auth"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Rootline mount point is missing."); + +const auth = createDesktopAuth(); +const syncCoordinator = new ProfileSyncCoordinator(auth); + +createRoot(root).render(); diff --git a/apps/desktop/src/native.ts b/apps/desktop/src/native.ts new file mode 100644 index 0000000..8daa015 --- /dev/null +++ b/apps/desktop/src/native.ts @@ -0,0 +1,93 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface Profile { + id: string; + name: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; + createdAt: string; + updatedAt: string; +} + +export interface ScanRequest { + operationId: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; +} + +export interface ScanPlan { + operationId: string; + sourceRoot: string; + targetRoot: string; + sourceFingerprint: string; + targetFingerprint: string; + targetCaseSensitive: boolean; + planFingerprint: string; + missing: string[]; + diffEntries: DiffEntry[]; + skippedLinks: string[]; +} + +export type DiffStatus = "missing" | "exists" | "excluded" | "unreadable"; + +export interface DiffEntry { + relativePath: string; + status: DiffStatus; +} + +export type DirectoryStatus = "created" | "already-exists" | "failed"; + +export interface ApplyResult { + runId: string; + startedAt: string; + finishedAt: string; + cancelled: boolean; + directories: Array<{ relativePath: string; status: DirectoryStatus; error?: string }>; +} + +export interface ProfileRootAvailability { + sourceAvailable: boolean; + targetAvailable: boolean; +} + +export interface NativeGateway { + chooseFolder(input: { role: "source" | "target" }): Promise; + inspectProfileRoots(input: { sourcePath: string; targetPath: string }): Promise; + scan(request: ScanRequest): Promise; + apply(input: { request: ScanRequest; plan: ScanPlan; selected: string[]; profileId?: string }): Promise; + cancel(operationId: string): Promise; + listProfiles(): Promise; + saveProfile(profile: Profile): Promise; + deleteProfile(id: string): Promise; +} + +export const tauriGateway: NativeGateway = { + chooseFolder: ({ role }) => invoke("choose_folder", { role }), + inspectProfileRoots: (input) => invoke("inspect_saved_profile_roots", input), + scan: (request) => invoke("scan_directories", { request }), + apply: (command) => invoke("apply_directories", { command }), + cancel: (operationId) => invoke("cancel_operation", { operationId }), + listProfiles: () => invoke("list_profiles"), + saveProfile: (profile) => invoke("save_profile", { profile }), + deleteProfile: (id) => invoke("delete_profile", { id }), +}; + +export interface NativeFailure { + code: string; + message: string; + details?: Record; +} + +export function nativeFailure(error: unknown): NativeFailure { + if (typeof error === "object" && error !== null && "code" in error) { + const value = error as Partial; + return { + code: typeof value.code === "string" ? value.code : "INTERNAL", + message: typeof value.message === "string" ? value.message : "Unexpected native error.", + ...(value.details ? { details: value.details } : {}), + }; + } + return { code: "INTERNAL", message: error instanceof Error ? error.message : "Unexpected native error." }; +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css new file mode 100644 index 0000000..c1fe7fc --- /dev/null +++ b/apps/desktop/src/styles.css @@ -0,0 +1,101 @@ +:root { + color-scheme: light dark; + font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif; + font-synthesis: none; + --graphite-950: #111619; + --graphite-900: #171c1f; + --graphite-800: #232a2e; + --graphite-700: #354046; + --fog-50: #f5f7f6; + --fog-100: #e8edeb; + --fog-300: #b8c1bd; + --cyan-500: #23bac7; + --cyan-600: #1299a6; + --moss-500: #587b64; + --amber-500: #d69b3b; + --danger: #c7584f; + background: var(--fog-50); + color: var(--graphite-900); +} + +* { box-sizing: border-box; } +html, body, #root { min-width: 320px; min-height: 100%; margin: 0; } +button, input { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .45; } +.topbar-actions, .auth-controls { display: flex; align-items: center; gap: 8px; } +.auth-controls { position: relative; font-size: 11px; } +.auth-controls button { min-height: 30px; padding: 5px 8px; border: 1px solid var(--fog-300); border-radius: 5px; background: var(--fog-50); } +.auth-choice { position: absolute; z-index: 20; top: calc(100% + 8px); right: 0; width: 290px; padding: 14px; border: 1px solid var(--fog-300); border-radius: 8px; background: var(--fog-50); box-shadow: 0 16px 45px #20272a22; } +.auth-choice p { margin: 0 0 10px; line-height: 1.45; } +:focus-visible { outline: 3px solid color-mix(in srgb, var(--cyan-500) 68%, white); outline-offset: 3px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +.app-shell { min-height: 100vh; display: grid; grid-template-columns: 264px minmax(0, 1fr); background: var(--fog-50); } +.profile-rail { position: relative; display: flex; flex-direction: column; min-height: 100vh; padding: 26px 18px 18px; color: #f5f7f6; background: #171c1f; border-right: 1px solid #ffffff14; } +.brand { display: flex; align-items: center; gap: 11px; padding: 0 6px 34px; } +.brand-mark { width: 38px; height: 38px; overflow: visible; } +.brand-mark path { fill: none; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; } +.mark-soft { stroke: var(--fog-100); }.profile-rail .mark-soft { stroke: #e8edeb; }.mark-line { stroke: var(--cyan-500); }.mark-moss { fill: var(--moss-500); }.mark-amber { fill: var(--amber-500); } +.brand strong, .brand span { display: block; }.brand strong { color: #f5f7f6; font-size: 18px; letter-spacing: -.02em; }.brand span { color: #b8c1bd; font: 10px/1.4 ui-monospace, "SFMono-Regular", monospace; letter-spacing: .08em; } +.rail-label { display: flex; justify-content: space-between; padding: 0 8px 9px; color: #b8c1bd; font: 600 10px/1.3 ui-monospace, "SFMono-Regular", monospace; letter-spacing: .12em; text-transform: uppercase; } +.profile-list { display: grid; gap: 4px; min-height: 0; overflow-y: auto; } +.profile-item, .new-profile, .delete-profile { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; } +.profile-item { display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; align-items: center; padding: 11px 10px; border-radius: 9px; } +.profile-item:hover, .profile-item[aria-selected="true"] { background: #ffffff0d; } +.profile-item[aria-selected="true"] { box-shadow: inset 2px 0 var(--cyan-500); } +.profile-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--graphite-700); }.profile-item[aria-selected="true"] .profile-dot { background: var(--cyan-500); } +.profile-item strong, .profile-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.profile-item strong { font-size: 13px; }.profile-item small { margin-top: 3px; color: #b8c1bd; font-size: 10px; } +.new-profile { margin-top: 8px; padding: 10px; color: #b8c1bd; font-size: 12px; }.new-profile span { margin-right: 8px; color: var(--cyan-500); } +.delete-profile { padding: 8px 10px; color: #d8a5a1; font-size: 11px; }.delete-profile span { margin-right: 8px; color: var(--danger); } +.rail-footer { display: flex; align-items: center; gap: 8px; margin-top: auto; padding: 14px 8px 0; border-top: 1px solid #ffffff14; color: #b8c1bd; font-size: 11px; }.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--moss-500); box-shadow: 0 0 0 3px #587b6428; } + +.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: #11161999; } +.confirm-dialog { width: min(100%, 440px); padding: 24px; border: 1px solid var(--fog-300); border-radius: 12px; background: var(--fog-50); box-shadow: 0 24px 80px #0005; } +.confirm-dialog h2 { margin: 0; font-size: 21px; }.confirm-dialog p { margin: 14px 0 22px; color: #66706c; line-height: 1.55; } +.confirm-actions { display: flex; justify-content: flex-end; gap: 10px; }.danger-button { min-height: 44px; padding: 10px 16px; border: 1px solid var(--danger); border-radius: 7px; color: white; background: var(--danger); font-weight: 700; font-size: 12px; } + +.workbench { min-width: 0; min-height: 100vh; background-image: linear-gradient(#20272a0b 1px, transparent 1px), linear-gradient(90deg, #20272a0b 1px, transparent 1px); background-size: 32px 32px; } +.topbar { min-height: 78px; display: flex; align-items: center; justify-content: space-between; gap: 28px; padding: 0 42px; background: color-mix(in srgb, var(--fog-50) 92%, transparent); border-bottom: 1px solid var(--fog-100); } +.steps { display: flex; align-items: center; gap: 0; padding: 0; margin: 0; list-style: none; } +.steps li { display: flex; align-items: center; gap: 8px; color: #6d7773; font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; } +.steps li:not(:last-child)::after { content: ""; width: clamp(18px, 4vw, 58px); height: 1px; margin: 0 10px; background: var(--fog-300); } +.steps li.reached { color: var(--graphite-900); }.steps li span { display: grid; place-items: center; width: 23px; height: 23px; border: 1px solid var(--fog-300); border-radius: 50%; font: 600 10px/1 ui-monospace, monospace; }.steps li.reached span { border-color: var(--cyan-500); background: var(--cyan-500); color: var(--graphite-950); } +.locale-button, .quiet-button, .secondary-button { border: 1px solid var(--fog-300); color: var(--graphite-900); background: var(--fog-50); border-radius: 7px; } +.locale-button { padding: 7px 10px; font: 600 11px/1 ui-monospace, monospace; } +.workspace { width: min(100%, 1100px); margin: 0 auto; padding: 54px clamp(28px, 5vw, 76px) 70px; } +.stage { min-height: calc(100vh - 202px); } +.eyebrow { margin: 0 0 15px; color: var(--cyan-600); font: 700 10px/1.3 ui-monospace, "SFMono-Regular", monospace; letter-spacing: .15em; } +h1 { max-width: 760px; margin: 0; color: var(--graphite-950); font-size: clamp(36px, 5.5vw, 68px); font-weight: 520; line-height: .98; letter-spacing: -.055em; } +.lede { max-width: 650px; margin: 20px 0 38px; color: #59635f; font-size: 15px; line-height: 1.65; } +.root-pair { display: grid; grid-template-columns: minmax(0, 1fr) 34px minmax(0, 1fr); align-items: stretch; gap: 12px; } +.root-card { position: relative; min-height: 184px; display: flex; flex-direction: column; padding: 22px; overflow: hidden; border: 1px solid var(--fog-300); border-radius: 12px; background: color-mix(in srgb, var(--fog-50) 94%, white); box-shadow: 0 18px 40px #20272a0a; }.root-card.needs-rebind { border-color: var(--amber-500); } +.root-number { position: absolute; top: 14px; right: 16px; color: #9da7a2; font: 11px/1 ui-monospace, monospace; }.root-copy { min-width: 0; margin-top: 18px; }.root-copy small, .root-copy strong { display: block; }.root-copy small { margin-bottom: 9px; color: #727c77; font: 700 10px/1 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }.root-copy strong { overflow: hidden; color: var(--graphite-900); font: 600 13px/1.5 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; } +.root-card button { align-self: flex-start; margin-top: auto; padding: 8px 11px; border: 0; border-bottom: 1px solid var(--graphite-700); color: var(--graphite-800); background: transparent; font-size: 12px; }.flow-arrow { display: grid; place-items: center; color: var(--cyan-600); font: 24px/1 ui-monospace, monospace; } +.profile-save { display: grid; grid-template-columns: auto minmax(150px, 1fr) auto; align-items: center; gap: 12px; margin-top: 17px; padding: 13px 15px; border: 1px solid var(--fog-100); background: #ffffff80; border-radius: 9px; }.profile-save label { color: #66706c; font-size: 11px; }.profile-save input { min-width: 0; padding: 8px 10px; border: 1px solid var(--fog-300); border-radius: 6px; color: var(--graphite-900); background: var(--fog-50); } +.primary-button, .secondary-button { min-height: 44px; padding: 10px 16px; font-weight: 700; font-size: 12px; } +.primary-button { display: inline-flex; align-items: center; justify-content: center; gap: 18px; margin-top: 22px; border: 1px solid var(--graphite-950); border-radius: 7px; color: var(--fog-50); background: var(--graphite-950); box-shadow: inset 3px 0 var(--cyan-500); }.primary-button span { color: var(--cyan-500); font: 700 12px/1 ui-monospace, monospace; }.secondary-button { background: transparent; } +.error-card { display: flex; gap: 12px; align-items: flex-start; max-width: 720px; margin: -12px 0 24px; padding: 13px 15px; border-left: 3px solid var(--amber-500); color: var(--graphite-900); background: #d69b3b17; }.error-card > span { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: var(--graphite-950); background: var(--amber-500); font-weight: 800; }.error-card p { margin: 0; line-height: 1.5; } + +.progress-stage { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }.progress-stage h1 { max-width: 600px; font-size: clamp(32px, 5vw, 58px); }.progress-status { margin: 18px 0 28px; color: #68716d; }.scan-figure { position: relative; width: 240px; height: 150px; margin-bottom: 40px; overflow: hidden; }.scan-trunk { position: absolute; inset: 10px 28px; border-left: 2px solid var(--fog-300); }.scan-trunk::before, .scan-trunk i { content: ""; position: absolute; left: 0; width: 120px; height: 1px; background: var(--fog-300); }.scan-trunk::before { top: 0; }.scan-trunk i:nth-child(1) { top: 30px; width: 170px; }.scan-trunk i:nth-child(2) { top: 60px; width: 105px; }.scan-trunk i:nth-child(3) { top: 90px; width: 150px; }.scan-trunk i:nth-child(4) { top: 120px; width: 75px; }.scan-line { position: absolute; top: 0; bottom: 0; left: 28px; width: 3px; background: var(--cyan-500); box-shadow: 0 0 20px #23bac7aa; animation: rootline-scan 1.5s cubic-bezier(.65,0,.35,1) infinite alternate; } +@keyframes rootline-scan { from { transform: translateX(0); } to { transform: translateX(178px); } } + +.review-stage h1 { font-size: clamp(34px, 4.8vw, 58px); }.review-stage .lede { margin-bottom: 24px; }.safety-note { display: inline-block; margin: 0 0 16px; padding: 6px 9px; color: #6d5623; background: #d69b3b1c; font: 600 10px/1.3 ui-monospace, monospace; } +.diff-tree { border: 1px solid var(--fog-300); border-radius: 10px; overflow: hidden; background: color-mix(in srgb, var(--fog-50) 96%, white); box-shadow: 0 16px 45px #20272a0a; }.tree-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--fog-100); }.search-field { display: flex; align-items: center; flex: 1; min-width: 160px; height: 34px; padding: 0 10px; border: 1px solid var(--fog-300); border-radius: 6px; background: var(--fog-50); }.search-field:focus-within { outline: 3px solid color-mix(in srgb, var(--cyan-500) 68%, white); outline-offset: 2px; }.search-field svg { width: 16px; height: 16px; margin-right: 7px; fill: none; stroke: #68716d; stroke-width: 1.5; }.search-field input { width: 100%; border: 0; outline: 0; color: var(--graphite-900); background: transparent; font-size: 12px; }.segmented { display: flex; padding: 2px; border: 1px solid var(--fog-300); border-radius: 6px; }.segmented button { padding: 6px 9px; border: 0; border-radius: 4px; color: #65706b; background: transparent; font-size: 10px; }.segmented button[aria-pressed="true"] { color: var(--graphite-950); background: var(--fog-100); }.quiet-button { min-height: 32px; padding: 6px 9px; font-size: 10px; }.tree-summary { display: flex; justify-content: space-between; padding: 8px 14px; color: #69736f; border-bottom: 1px solid var(--fog-100); font: 10px/1.3 ui-monospace, monospace; }.tree-viewport { height: 456px; overflow: auto; contain: strict; }.tree-spacer { position: relative; min-width: 100%; }.tree-row { position: absolute; left: 0; right: 0; height: 38px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #20272a09; content-visibility: auto; cursor: pointer; }.tree-row:hover, .tree-row[aria-selected="true"] { background: #23bac70a; }.tree-row:focus-visible { z-index: 1; outline-offset: -3px; }.disclosure { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 23px; margin-right: 2px; color: var(--cyan-600); font: 18px/1 ui-monospace, monospace; }.branch { width: 25px; height: 12px; flex: 0 0 25px; border-left: 1px solid var(--fog-300); border-bottom: 1px solid var(--fog-300); }.selection-box { width: 15px; height: 15px; display: grid; place-items: center; flex: 0 0 15px; border: 1px solid #7f8a85; border-radius: 3px; color: var(--graphite-950); background: var(--fog-50); font: 700 10px/1 sans-serif; }.tree-row[aria-selected="true"] .selection-box { border-color: var(--cyan-600); background: var(--cyan-500); }.status-spacer { width: 15px; flex: 0 0 15px; }.folder-glyph { width: 14px; height: 10px; flex: 0 0 14px; border: 1px solid #7f8a85; border-radius: 2px; }.path-label { min-width: 0; overflow: hidden; color: var(--graphite-800); font: 11px/1.2 ui-monospace, "SFMono-Regular", monospace; text-overflow: ellipsis; white-space: nowrap; }.diff-status { flex: 0 0 auto; margin-left: auto; margin-right: 14px; padding: 3px 6px; border-radius: 999px; color: #59635f; background: var(--fog-100); font: 700 9px/1.2 ui-monospace, monospace; }.diff-status.status-missing { color: #087d88; background: #23bac71a; }.diff-status.status-exists { color: #42604d; background: #587b641c; }.diff-status.status-excluded { color: #6d5623; background: #d69b3b1c; }.diff-status.status-unreadable { color: var(--danger); background: #c7584f1c; }.review-actions { display: flex; justify-content: space-between; gap: 12px; margin-top: 18px; }.review-actions .primary-button { margin: 0; }.empty-state { display: grid; place-items: center; margin-bottom: 16px; padding: 58px 20px; border: 1px dashed var(--fog-300); text-align: center; background: #ffffff50; }.empty-state .brand-mark { width: 62px; height: 62px; margin-bottom: 14px; }.empty-state h2 { margin: 0; font-size: 19px; }.empty-state p { max-width: 470px; margin: 10px 0 0; color: #66706c; font-size: 13px; line-height: 1.55; } + +.result-stage { max-width: 760px; padding-top: 60px; }.result-mark { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 34px; border-radius: 50%; color: var(--fog-50); background: var(--moss-500); font-size: 26px; }.result-mark.cancelled { background: var(--amber-500); }.result-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 35px; border-block: 1px solid var(--fog-300); }.result-summary div { display: flex; align-items: baseline; gap: 9px; padding: 20px 14px; }.result-summary div + div { border-left: 1px solid var(--fog-300); }.result-summary strong { font: 500 30px/1 ui-monospace, monospace; }.result-summary span { color: #69736f; font-size: 11px; }.result-details { max-height: 260px; margin: 18px 0 0; padding: 0; overflow: auto; list-style: none; border: 1px solid var(--fog-300); border-radius: 8px; }.result-details li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 16px; padding: 10px 12px; }.result-details li + li { border-top: 1px solid var(--fog-100); }.result-path { overflow: hidden; font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }.result-status { color: #69736f; font-size: 11px; }.result-details small { grid-column: 1 / -1; color: var(--danger); }.failure-action { padding-left: 12px; color: var(--danger); border-left: 3px solid var(--danger); font-size: 12px; } + +@media (prefers-color-scheme: dark) { + :root { --fog-50: #171c1f; --fog-100: #252c30; --fog-300: #49545a; --graphite-950: #f0f4f2; --graphite-900: #e5ebe8; --graphite-800: #cdd6d2; background: #111619; color: #e5ebe8; } + .app-shell, .workbench { background-color: #111619; }.profile-rail { background: #0d1113; }.topbar { background: #171c1fed; }.lede, .progress-status, .empty-state p, .tree-summary, .steps li, .result-summary span, .segmented button { color: #aeb8b3; }.root-card, .diff-tree { background: #171c1f; }.profile-save, .empty-state { background: #171c1f80; }.profile-save input, .search-field, .locale-button, .quiet-button, .secondary-button { background: #111619; color: #e5ebe8; }.root-copy small, .profile-save label { color: #aeb8b3; } +} + +@media (max-width: 900px) { + .app-shell { grid-template-columns: 1fr; }.profile-rail { min-height: auto; padding: 14px 18px; }.brand { padding-bottom: 12px; }.rail-label { padding-right: 150px; }.profile-list { display: flex; gap: 6px; padding: 2px 0 8px; overflow-x: auto; overflow-y: hidden; }.profile-item { width: 190px; flex: 0 0 190px; }.new-profile { position: absolute; top: 22px; right: 18px; width: auto; }.rail-footer { margin-top: 0; padding-top: 8px; border: 0; }.topbar { padding: 0 22px; }.workspace { padding-top: 38px; }.stage { min-height: auto; }.tree-tools { flex-wrap: wrap; } +} +@media (max-width: 620px) { + .steps li { font-size: 0; }.steps li:not(:last-child)::after { width: 12px; margin: 0 5px; }.topbar { min-height: 64px; }.root-pair { grid-template-columns: 1fr; }.flow-arrow { transform: rotate(90deg); }.profile-save { grid-template-columns: 1fr; }.tree-tools { align-items: stretch; }.search-field { flex-basis: 100%; }.review-actions { flex-direction: column-reverse; }.review-actions button { width: 100%; }.result-summary { grid-template-columns: 1fr; }.result-summary div + div { border-left: 0; border-top: 1px solid var(--fog-300); } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } +} diff --git a/apps/desktop/src/test/App.test.tsx b/apps/desktop/src/test/App.test.tsx new file mode 100644 index 0000000..12d97ba --- /dev/null +++ b/apps/desktop/src/test/App.test.tsx @@ -0,0 +1,407 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import axe from "axe-core"; +import { describe, expect, test, vi } from "vitest"; + +import { App } from "../App"; +import { DiffTree } from "../components/DiffTree"; +import type { NativeGateway, ScanPlan } from "../native"; +import type { AuthController, AuthSnapshot, ProfileSyncCoordinator } from "../auth"; + +const plan: ScanPlan = { + operationId: "scan-1", + sourceRoot: "/projects/source", + targetRoot: "/projects/target", + sourceFingerprint: "source-fp", + targetFingerprint: "target-fp", + targetCaseSensitive: false, + planFingerprint: "plan-fp", + missing: ["docs", "docs/api", "src", "src/components"], + diffEntries: [ + { relativePath: "docs", status: "missing" }, + { relativePath: "docs/api", status: "missing" }, + { relativePath: "existing", status: "exists" }, + { relativePath: "private", status: "excluded" }, + { relativePath: "locked", status: "unreadable" }, + { relativePath: "src", status: "missing" }, + { relativePath: "src/components", status: "missing" }, + ], + skippedLinks: [], +}; + +function gateway(overrides: Partial = {}): NativeGateway { + return { + chooseFolder: vi.fn(async ({ role }) => role === "source" ? "/projects/source" : "/projects/target"), + inspectProfileRoots: vi.fn(async () => ({ sourceAvailable: true, targetAvailable: true })), + scan: vi.fn(async () => plan), + apply: vi.fn(async () => ({ + runId: "run-1", + startedAt: "2026-08-15T00:00:00Z", + finishedAt: "2026-08-15T00:00:01Z", + cancelled: false, + directories: [ + { relativePath: "docs", status: "created" as const }, + { relativePath: "docs/api", status: "created" as const }, + ], + })), + cancel: vi.fn(async () => undefined), + listProfiles: vi.fn(async () => []), + saveProfile: vi.fn(async (profile) => profile), + deleteProfile: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe("Rootline desktop workflow", () => { + test("moves from choosing roots through scan, review, and apply results", async () => { + const user = userEvent.setup(); + const native = gateway(); + render(); + + expect(screen.getByRole("heading", { name: "Choose two roots" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Choose source folder" })); + await user.click(screen.getByRole("button", { name: "Choose target folder" })); + expect(screen.getByText("/projects/source")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Scan differences" })); + const reviewHeading = await screen.findByRole("heading", { name: "Review 4 missing folders" }); + expect(reviewHeading).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Clear selection" })); + await user.click(screen.getByRole("treeitem", { name: "docs/api" })); + expect(screen.getByRole("button", { name: "Create selected folders" })).toHaveTextContent("2"); + await user.click(screen.getByRole("button", { name: "Create selected folders" })); + + const resultHeading = await screen.findByRole("heading", { name: "2 folders created" }); + expect(resultHeading).toHaveFocus(); + expect(native.apply).toHaveBeenCalledWith(expect.objectContaining({ selected: ["docs", "docs/api"] })); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.getByRole("button", { name: "Scan differences" })).toHaveFocus()); + }); + + test("shows actionable loading, empty, failure, and rebind states", async () => { + const user = userEvent.setup(); + let resolveScan: ((value: ScanPlan) => void) | undefined; + const pending = new Promise((resolve) => { resolveScan = resolve; }); + const native = gateway({ scan: vi.fn(() => pending) }); + const view = render(); + await user.click(screen.getByRole("button", { name: "Choose source folder" })); + await user.click(screen.getByRole("button", { name: "Choose target folder" })); + await user.click(screen.getByRole("button", { name: "Scan differences" })); + expect(screen.getByRole("status")).toHaveTextContent("Tracing folder structure"); + await act(async () => resolveScan?.({ ...plan, missing: [] })); + expect(await screen.findByText("The target already has this structure.")).toBeInTheDocument(); + + view.unmount(); + const failing = gateway({ scan: vi.fn(async () => { throw { code: "SOURCE_NOT_FOUND", message: "gone" }; }) }); + render(); + await user.click(screen.getByRole("button", { name: "Scan differences" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Choose a new source folder"); + expect(screen.getByRole("button", { name: "Rebind source" })).toBeInTheDocument(); + }); + + test("marks unavailable saved roots for rebind as soon as a profile is selected", async () => { + const user = userEvent.setup(); + const profile = { + id: "detached", name: "Detached", sourcePath: "/missing/source", targetPath: "/missing/target", + exclusions: [], createdAt: "x", updatedAt: "x", + }; + const inspectProfileRoots = vi.fn(async () => ({ sourceAvailable: false, targetAvailable: false })); + const native = gateway({ + listProfiles: vi.fn(async () => [profile]), + inspectProfileRoots, + } as Partial); + render(); + + await user.click(await screen.findByRole("option", { name: "Detached" })); + + expect(await screen.findByRole("button", { name: "Rebind source" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Rebind target" })).toBeInTheDocument(); + expect(inspectProfileRoots).toHaveBeenCalledWith({ sourcePath: profile.sourcePath, targetPath: profile.targetPath }); + expect(native.scan).not.toHaveBeenCalled(); + }); + + test("supports keyboard profile navigation, restores focus, Vietnamese copy, and has no serious axe violations", async () => { + const user = userEvent.setup(); + const native = gateway({ + listProfiles: vi.fn(async () => [ + { id: "one", name: "One", sourcePath: "/one", targetPath: "/target-one", exclusions: [], createdAt: "x", updatedAt: "x" }, + { id: "two", name: "Two", sourcePath: "/two", targetPath: "/target-two", exclusions: [], createdAt: "x", updatedAt: "x" }, + ]), + }); + const { container } = render(); + const first = await screen.findByRole("option", { name: "One" }); + first.focus(); + await user.keyboard("{ArrowDown}{Enter}"); + expect(screen.getByText("/two")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + expect(document.documentElement.lang).toBe("vi"); + expect(screen.getByRole("heading", { name: "Chọn hai thư mục gốc" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Quét khác biệt" })); + await user.click(await screen.findByRole("button", { name: "Tạo các thư mục đã chọn" })); + expect((await screen.findAllByText("đã tạo")).length).toBeGreaterThan(0); + expect(screen.getByText("đã tồn tại")).toBeInTheDocument(); + expect(screen.getByText("thất bại")).toBeInTheDocument(); + + const results = await axe.run(container); + expect(results.violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious")).toEqual([]); + }); + + test("focuses progress and choose transitions and localizes native stale errors", async () => { + const user = userEvent.setup(); + let rejectScan: ((reason: unknown) => void) | undefined; + const scanPending = new Promise((_resolve, reject) => { rejectScan = reject; }); + const native = gateway({ + scan: vi.fn(() => scanPending), + cancel: vi.fn(async () => rejectScan?.({ code: "CANCELLED", message: "raw cancelled" })), + }); + const first = render(); + await user.click(screen.getByRole("button", { name: "Scan differences" })); + expect(screen.getByRole("heading", { name: "Tracing folder structure…" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Scan differences" })).toHaveFocus()); + expect(document.body).not.toHaveFocus(); + first.unmount(); + + const stale = gateway({ apply: vi.fn(async () => { throw { code: "STALE_PLAN", message: "raw Rust stale" }; }) }); + const view = render(); + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + await user.click(screen.getByRole("button", { name: "Quét khác biệt" })); + await user.click(await screen.findByRole("button", { name: "Tạo các thư mục đã chọn" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Thư mục đã thay đổi"); + expect(screen.queryByText("raw Rust stale")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Chọn cặp khác" })); + expect(screen.getByRole("heading", { name: "Chọn hai thư mục gốc" })).toHaveFocus(); + view.unmount(); + }); + + test("renders localized per-path results, failure details, and cancelled runs", async () => { + const user = userEvent.setup(); + const native = gateway({ + apply: vi.fn(async () => ({ + runId: "partial", startedAt: "x", finishedAt: "y", cancelled: true, + directories: [ + { relativePath: "docs", status: "created" as const }, + { relativePath: "existing", status: "already-exists" as const }, + { relativePath: "blocked", status: "failed" as const, error: "Permission denied" }, + ], + })), + }); + render(); + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + await user.click(screen.getByRole("button", { name: "Quét khác biệt" })); + await user.click(await screen.findByRole("button", { name: "Tạo các thư mục đã chọn" })); + expect(await screen.findByRole("heading", { name: "Đã hủy sau khi tạo 1 thư mục" })).toBeInTheDocument(); + expect(screen.getByText("docs").closest("li")).toHaveTextContent("đã tạo"); + expect(screen.getByText("existing").closest("li")).toHaveTextContent("đã tồn tại"); + expect(screen.getByText("blocked").closest("li")).toHaveTextContent("thất bại"); + expect(screen.getByText("Permission denied")).toBeInTheDocument(); + expect(screen.getByText("Kiểm tra quyền truy cập rồi chạy lại.")).toBeInTheDocument(); + }); + + test("has zero axe violations while Review is mounted", async () => { + const user = userEvent.setup(); + const { container } = render(); + await user.click(screen.getByRole("button", { name: "Scan differences" })); + await screen.findByRole("heading", { name: "Review 4 missing folders" }); + expect(screen.getByRole("tree", { name: "Folder differences" })).toHaveAttribute("aria-multiselectable", "true"); + expect(screen.getByRole("treeitem", { name: "existing" })).toHaveTextContent("Exists"); + expect(screen.getByRole("treeitem", { name: "private" })).toHaveTextContent("Excluded"); + expect(screen.getByRole("treeitem", { name: "locked" })).toHaveTextContent("Unreadable"); + expect(screen.getByRole("button", { name: "Select all missing" })).toBeInTheDocument(); + const results = await axe.run(container); + expect(results.violations).toEqual([]); + }); + + test("reconciles visible profiles after hosted sync or destructive cleanup commits", async () => { + const user = userEvent.setup(); + const profile = { id: "remote", name: "Remote", sourcePath: "/private/path", targetPath: "/target", exclusions: [], createdAt: "x", updatedAt: "x" }; + const listProfiles = vi.fn().mockResolvedValueOnce([profile]).mockResolvedValueOnce([]); + const listeners = new Set<(snapshot: AuthSnapshot) => void>(); + let state: AuthSnapshot = { configured: true, loading: false, dataVersion: 0, user: { sub: "alice", permissions: ["rootline:profiles:sync"] } }; + const auth: AuthController = { + snapshot: () => state, + subscribe: (listener) => { listeners.add(listener); listener(state); return () => listeners.delete(listener); }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), dispose: vi.fn(), + }; + render(); + await user.click(await screen.findByRole("option", { name: "Remote" })); + expect(screen.getByText("/private/path")).toBeInTheDocument(); + state = { ...state, dataVersion: 1 }; + listeners.forEach((listener) => listener(state)); + await waitFor(() => expect(screen.queryByRole("option", { name: "Remote" })).not.toBeInTheDocument()); + expect(screen.queryByText("/private/path")).not.toBeInTheDocument(); + }); + + test("enforces shared profile limits in the UI with localized errors before native persistence", async () => { + const user = userEvent.setup(); + const native = gateway(); + const exactCodePoints = (count: number) => "✈️".repeat(Math.floor(count / 2)) + (count % 2 ? "x" : ""); + const boundary = { + id: "limits", + name: exactCodePoints(80), + sourcePath: exactCodePoints(4096), + targetPath: exactCodePoints(4096), + exclusions: Array.from({ length: 100 }, () => exactCodePoints(256)), + createdAt: "x", + updatedAt: "x", + }; + const view = render(); + const name = screen.getByRole("textbox", { name: "Profile name" }); + expect(name).not.toHaveAttribute("minlength"); + expect(name).not.toHaveAttribute("maxlength"); + expect(name).toBeRequired(); + + await user.click(screen.getByRole("button", { name: "Save profile" })); + expect(native.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ + name: boundary.name, + sourcePath: boundary.sourcePath, + targetPath: boundary.targetPath, + exclusions: boundary.exclusions, + })); + vi.mocked(native.saveProfile).mockClear(); + + fireEvent.change(name, { target: { value: exactCodePoints(81) } }); + await user.click(screen.getByRole("button", { name: "Save profile" })); + expect(screen.getByRole("alert")).toHaveTextContent("Profile name must contain 1–80 characters."); + expect(native.saveProfile).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + await user.click(screen.getByRole("button", { name: "Lưu hồ sơ" })); + expect(screen.getByRole("alert")).toHaveTextContent("Tên hồ sơ phải có từ 1–80 ký tự."); + expect(native.saveProfile).not.toHaveBeenCalled(); + + view.unmount(); + for (const invalidProfile of [ + { ...boundary, id: "path-over", name: "Valid", sourcePath: exactCodePoints(4097) }, + { ...boundary, id: "pattern-over", name: "Valid", exclusions: [exactCodePoints(257)] }, + { ...boundary, id: "array-over", name: "Valid", exclusions: Array.from({ length: 101 }, () => "x") }, + ]) { + const invalidView = render(); + await user.click(screen.getByRole("button", { name: "Save profile" })); + expect(screen.getByRole("alert")).toHaveTextContent("This profile exceeds Rootline’s limits."); + expect(native.saveProfile).not.toHaveBeenCalled(); + invalidView.unmount(); + } + }); + + test("localizes native profile validation failures without persisting UI state", async () => { + const user = userEvent.setup(); + const native = gateway({ + saveProfile: vi.fn(async () => { throw { code: "VALIDATION_FAILED", message: "raw native limit" }; }), + }); + render(); + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + await user.click(screen.getByRole("button", { name: "Lưu hồ sơ" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Hồ sơ vượt quá giới hạn cho phép"); + expect(screen.queryByText("raw native limit")).not.toBeInTheDocument(); + }); + + test("deletes a profile through a keyboard-accessible localized confirmation and queues sync", async () => { + const user = userEvent.setup(); + const profile = { + id: "delete-me", name: "Archive", sourcePath: "/archive", targetPath: "/backup", + exclusions: [], createdAt: "x", updatedAt: "x", + }; + const native = gateway({ listProfiles: vi.fn(async () => [profile]) }); + const coordinator = { profileEdited: vi.fn() } as unknown as ProfileSyncCoordinator; + render(); + + const option = await screen.findByRole("option", { name: "Archive" }); + await user.click(option); + option.focus(); + await user.keyboard("{Delete}"); + const englishDialog = screen.getByRole("dialog", { name: "Delete profile Archive?" }); + expect(englishDialog).toHaveTextContent("Run history is preserved"); + expect(screen.getByRole("button", { name: "Cancel profile deletion" })).toHaveFocus(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(option).toHaveFocus()); + + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + await user.click(screen.getByRole("button", { name: "Xóa hồ sơ Archive" })); + const vietnameseDialog = screen.getByRole("dialog", { name: "Xóa hồ sơ Archive?" }); + expect(vietnameseDialog).toHaveTextContent("Lịch sử lượt chạy vẫn được giữ lại"); + await user.click(screen.getByRole("button", { name: "Xác nhận xóa hồ sơ" })); + + await waitFor(() => expect(native.deleteProfile).toHaveBeenCalledWith("delete-me")); + expect(screen.queryByRole("option", { name: "Archive" })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Tên hồ sơ" })).toHaveValue(""); + expect(coordinator.profileEdited).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Hồ sơ mới" })).toHaveFocus(); + }); +}); + +describe("DiffTree virtualization", () => { + test("renders a bounded window for a 50,000-folder fixture and keeps subtree selection", async () => { + const user = userEvent.setup(); + const paths = Array.from({ length: 50_000 }, (_, index) => `root/group-${Math.floor(index / 100)}/folder-${index}`); + const entries = paths.map((relativePath) => ({ relativePath, status: "missing" as const })); + const onSelectionChange = vi.fn(); + const { container } = render( + , + ); + + expect(screen.getByRole("tree")).not.toHaveAttribute("aria-rowcount"); + expect(container.querySelectorAll('[role="treeitem"]').length).toBeLessThan(80); + await user.type(screen.getByRole("searchbox", { name: "Search folders" }), "folder-49999"); + expect(await screen.findByRole("treeitem", { name: paths[49_999]! })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Clear selection" })); + expect(onSelectionChange).toHaveBeenLastCalledWith(new Set()); + }); + + test("uses named treeitems with roving keyboard navigation and parent dependency selection", async () => { + const user = userEvent.setup(); + const entries = ["docs", "docs/api", "docs/api/v2", "src"].map((relativePath) => ({ relativePath, status: "missing" as const })); + const onSelectionChange = vi.fn(); + const view = render(); + const docs = screen.getByRole("treeitem", { name: "docs" }); + const api = screen.getByRole("treeitem", { name: "docs/api" }); + expect(screen.getByRole("tree")).not.toHaveAttribute("aria-rowcount"); + expect(docs).toHaveAttribute("tabindex", "0"); + expect(view.container.querySelector('[role="treeitem"] button, [role="treeitem"] input')).toBeNull(); + docs.focus(); + await user.keyboard("{ArrowDown}"); + expect(api).toHaveFocus(); + await user.keyboard(" "); + expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(["docs", "docs/api", "docs/api/v2"])); + await user.keyboard("{ArrowLeft}"); + expect(api).toHaveFocus(); + await user.keyboard("{ArrowLeft}"); + expect(docs).toHaveFocus(); + await user.keyboard("{End}"); + expect(screen.getByRole("treeitem", { name: "src" })).toHaveFocus(); + }); + + test("provides explicit localized controls to collapse and expand the whole tree", async () => { + const user = userEvent.setup(); + const entries = ["docs", "docs/api", "src"].map((relativePath) => ({ relativePath, status: "missing" as const })); + render(); + + await user.click(screen.getByRole("button", { name: "Collapse all" })); + expect(screen.queryByRole("treeitem", { name: "docs/api" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Expand all" })); + expect(screen.getByRole("treeitem", { name: "docs/api" })).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/test/AuthControls.test.tsx b/apps/desktop/src/test/AuthControls.test.tsx new file mode 100644 index 0000000..bed5870 --- /dev/null +++ b/apps/desktop/src/test/AuthControls.test.tsx @@ -0,0 +1,175 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import axe from "axe-core"; +import { expect, test, vi } from "vitest"; + +import { AuthControls } from "../components/AuthControls"; +import type { AuthController, AuthSnapshot } from "../auth"; + +test("sign-out explicitly keeps or removes local synced profiles without rendering tokens", async () => { + const user = userEvent.setup(); + const state: AuthSnapshot = { configured: true, loading: false, user: { sub: "alice", name: "Alice", permissions: ["rootline:profiles:sync"] }, dataVersion: 0 }; + const auth: AuthController = { + snapshot: () => state, + subscribe: (listener) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), + signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), + signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), + resolveEpochReset: vi.fn(async () => undefined), + resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), + dispose: vi.fn(), + }; + const view = render(); + expect(view.container.textContent).not.toMatch(/access_token|refresh_token|bearer/i); + await user.click(screen.getByRole("button", { name: "Sign out" })); + await user.click(screen.getByRole("button", { name: "Keep local profiles" })); + expect(auth.signOut).toHaveBeenCalledWith(false); + await user.click(screen.getByRole("button", { name: "Sign out" })); + await user.click(screen.getByRole("button", { name: "Remove local profiles" })); + expect(auth.signOut).toHaveBeenCalledWith(true); + + await user.click(screen.getByRole("button", { name: "Delete hosted data" })); + expect(screen.getByRole("dialog", { name: "Delete hosted data options" })).toHaveTextContent(/epoch will rotate/i); + await user.click(screen.getByRole("button", { name: "Keep local profiles" })); + expect(auth.deleteAccountData).toHaveBeenCalledWith(false); + + await user.click(screen.getByRole("button", { name: "Delete hosted data" })); + await user.click(screen.getByRole("button", { name: "Remove local profiles" })); + expect(auth.deleteAccountData).toHaveBeenCalledWith(true); +}); + +test("requires an explicit keep-or-remove decision after an epoch reset", async () => { + const user = userEvent.setup(); + const state: AuthSnapshot = { + configured: true, loading: false, dataVersion: 0, epochResetRequired: true, + user: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + }; + const auth: AuthController = { + snapshot: () => state, + subscribe: (listener) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), dispose: vi.fn(), + }; + render(); + expect(screen.getByRole("button", { name: "Sync now" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Review reset" })); + expect(screen.getByRole("dialog", { name: "Hosted reset options" })).toHaveTextContent(/stale queued changes will be discarded/i); + await user.click(screen.getByRole("button", { name: "Keep local profiles" })); + expect(auth.resolveEpochReset).toHaveBeenCalledWith(false); +}); + +test("explains that accepting an existing epoch keeps explicitly consented device profiles queued", async () => { + const user = userEvent.setup(); + const state = { + configured: true, loading: false, dataVersion: 0, epochResetRequired: true, + epochResetPreservesConsentedOutbox: true, + user: { sub: "device-two", permissions: ["rootline:profiles:sync"] }, + } as AuthSnapshot & { epochResetPreservesConsentedOutbox: boolean }; + const auth = { + snapshot: () => state, + subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), + resolveAccountClaim: vi.fn(async () => undefined), sync: vi.fn(async () => undefined), dispose: vi.fn(), + } satisfies AuthController; + render(); + await user.click(screen.getByRole("button", { name: "Review reset" })); + expect(screen.getByRole("dialog", { name: "Hosted reset options" })) + .toHaveTextContent(/explicitly consented.*remain queued.*uploaded/i); +}); + +test("requires explicit consent before existing absolute-path profiles are claimed by an account", async () => { + const user = userEvent.setup(); + const state: AuthSnapshot = { + configured: true, loading: false, dataVersion: 0, accountClaimRequired: true, + user: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + }; + const auth = { + snapshot: () => state, + subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), + resolveAccountClaim: vi.fn(async () => undefined), sync: vi.fn(async () => undefined), dispose: vi.fn(), + } satisfies AuthController; + render(); + expect(screen.getByRole("button", { name: "Sync now" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Review local profiles" })); + expect(screen.getByRole("dialog", { name: "Local profile upload options" })).toHaveTextContent(/absolute paths/i); + await user.click(screen.getByRole("button", { name: "Keep local only" })); + expect(auth.resolveAccountClaim).toHaveBeenCalledWith(false); +}); + +test("fully localizes controls and provides a trapped, Escape-restoring Vietnamese dialog", async () => { + const user = userEvent.setup(); + const state: AuthSnapshot = { + configured: true, loading: false, dataVersion: 0, + user: { sub: "alice", name: "Alice", permissions: ["rootline:profiles:sync"] }, + }; + const auth = { + snapshot: () => state, + subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), + signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), + resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), dispose: vi.fn(), + } satisfies AuthController; + const view = render(); + + const trigger = screen.getByRole("button", { name: "Đăng xuất" }); + expect(screen.getByRole("button", { name: "Đồng bộ ngay" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Xóa dữ liệu lưu trữ" })).toBeInTheDocument(); + expect(screen.queryByText("Sync now")).not.toBeInTheDocument(); + await user.click(trigger); + const dialog = screen.getByRole("dialog", { name: "Tùy chọn đăng xuất" }); + expect(dialog).toHaveTextContent(/lịch sử lượt chạy vẫn được giữ lại/i); + const cancel = screen.getByRole("button", { name: "Hủy" }); + expect(cancel).toHaveFocus(); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(screen.getByRole("button", { name: "Xóa hồ sơ cục bộ" })).toHaveFocus(); + await user.keyboard("{Escape}"); + expect(dialog).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + expect((await axe.run(view.container)).violations).toEqual([]); +}); + +test("offers cancel and retry for a pending browser sign-in and explains quarantined changes", async () => { + const user = userEvent.setup(); + const pending: AuthSnapshot = { configured: true, loading: false, signInPending: true, user: null, dataVersion: 0 }; + const auth = { + snapshot: () => pending, + subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(pending); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), + signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), + resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), dispose: vi.fn(), + } satisfies AuthController; + const view = render(); + expect(screen.getByRole("status")).toHaveTextContent("Waiting for sign-in in your browser"); + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + await user.click(screen.getByRole("button", { name: "Try sign-in again" })); + expect(auth.cancelSignIn).toHaveBeenCalledTimes(1); + expect(auth.signIn).toHaveBeenCalledTimes(1); + + const synced = { ...pending, signInPending: false, user: { sub: "alice", permissions: [] }, quarantinedMutations: 2 } satisfies AuthSnapshot; + view.rerender( synced, + subscribe: (listener) => { listener(synced); return () => undefined; }, + }} locale="en" />); + expect(screen.getByRole("status")).toHaveTextContent("2 local profile changes could not be uploaded"); + expect(screen.getByRole("status")).toHaveTextContent("Edit and save the affected profiles"); + expect(screen.getByRole("status")).toHaveTextContent("delete"); +}); diff --git a/apps/desktop/src/test/auth.test.ts b/apps/desktop/src/test/auth.test.ts new file mode 100644 index 0000000..7d4e70a --- /dev/null +++ b/apps/desktop/src/test/auth.test.ts @@ -0,0 +1,469 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { OidcClient, WebStorageStateStore, type AsyncStorage } from "oidc-client-ts"; + +import { + DesktopAuthController, + OIDC_BROWSER_FLOW_TIMEOUT_MS, + OIDC_SCOPE, + ProfileSyncCoordinator, + createOidcSettings, + projectUser, + readOidcConfiguration, + validateCallbackUrl, + type AuthController, + type OidcConfiguration, +} from "../auth"; + +const tauriMocks = vi.hoisted(() => ({ + getCurrent: vi.fn(async () => null as string[] | null), + invoke: vi.fn(), + listen: vi.fn(async () => vi.fn()), + onOpenUrl: vi.fn(async (_handler: (urls: string[]) => void) => vi.fn()), +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: tauriMocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: tauriMocks.listen })); +vi.mock("@tauri-apps/plugin-deep-link", () => ({ getCurrent: tauriMocks.getCurrent, onOpenUrl: tauriMocks.onOpenUrl })); + +class MemoryAsyncStorage implements AsyncStorage { + private readonly values = new Map(); + get length() { return Promise.resolve(this.values.size); } + clear() { this.values.clear(); return Promise.resolve(); } + getItem(key: string) { return Promise.resolve(this.values.get(key) ?? null); } + key(index: number) { return Promise.resolve([...this.values.keys()][index] ?? null); } + removeItem(key: string) { this.values.delete(key); return Promise.resolve(); } + setItem(key: string, value: string) { this.values.set(key, value); return Promise.resolve(); } +} + +describe("Rootline desktop authentication boundary", () => { + const config = { + authority: "https://auth.baole.space/application/o/rootline/", + clientId: "rootline-desktop", + apiUrl: "https://rootline-api.baole.space", + redirectUri: "rootline://auth/callback" as const, + scope: OIDC_SCOPE, + } satisfies OidcConfiguration; + + beforeEach(() => { + tauriMocks.invoke.mockReset(); + tauriMocks.getCurrent.mockReset().mockResolvedValue(null); + tauriMocks.listen.mockReset().mockResolvedValue(vi.fn()); + tauriMocks.onOpenUrl.mockReset().mockResolvedValue(vi.fn()); + }); + + test("uses the public PKCE client scopes and fails closed on partial configuration", () => { + expect(OIDC_SCOPE).toBe("openid profile email permissions offline_access"); + expect(readOidcConfiguration({})).toBeNull(); + expect(() => readOidcConfiguration({ VITE_AUTHENTIK_ISSUER: "https://auth.baole.space/application/o/rootline/" })) + .toThrow(/CLIENT_ID/); + expect(readOidcConfiguration({ + VITE_AUTHENTIK_ISSUER: "https://auth.baole.space/application/o/rootline/", + VITE_AUTHENTIK_CLIENT_ID: "rootline-desktop", + VITE_ROOTLINE_SYNC_API: "https://rootline-api.baole.space", + })).toEqual(expect.objectContaining({ + redirectUri: "rootline://auth/callback", + scope: OIDC_SCOPE, + })); + }); + + test("accepts only the exact callback scheme, host, path, state, and code", () => { + expect(validateCallbackUrl("rootline://auth/callback?code=abc&state=expected", "expected").toString()) + .toBe("rootline://auth/callback?code=abc&state=expected"); + for (const value of [ + "https://auth/callback?code=abc&state=expected", + "rootline://evil/callback?code=abc&state=expected", + "rootline://auth/callback/extra?code=abc&state=expected", + "rootline://auth/callback?code=abc&state=wrong", + "rootline://auth/callback?state=expected", + "rootline://auth/callback?code=abc&state=expected&code=second", + ]) expect(() => validateCallbackUrl(value, "expected")).toThrow(/AUTH_CALLBACK_INVALID/); + }); + + test("creates an actual PKCE+nonce request and consumes callback state only once outside browser storage", async () => { + const browserWrite = vi.spyOn(Storage.prototype, "setItem"); + const storage = new MemoryAsyncStorage(); + const stateStore = new WebStorageStateStore({ prefix: "test.state.", store: storage }); + const userStore = new WebStorageStateStore({ prefix: "test.user.", store: storage }); + const authority = "https://auth.baole.space/application/o/rootline/"; + const settings = createOidcSettings({ + authority, clientId: "rootline-desktop", apiUrl: "https://rootline-api.baole.space", + redirectUri: "rootline://auth/callback", scope: OIDC_SCOPE, + }, stateStore, userStore); + const client = new OidcClient({ + ...settings, + metadata: { + issuer: authority, + authorization_endpoint: `${authority}authorize/`, + token_endpoint: `${authority}token/`, + }, + }); + const request = await client.createSigninRequest({ nonce: "nonce-must-match-id-token" }); + const url = new URL(request.url); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(url.searchParams.get("nonce")).toBe("nonce-must-match-id-token"); + expect(browserWrite).not.toHaveBeenCalled(); + + const state = url.searchParams.get("state")!; + await expect(client.readSigninResponseState(`rootline://auth/callback?code=x&state=wrong`, true)).rejects.toThrow(/state/i); + const accepted = await client.readSigninResponseState(`rootline://auth/callback?code=x&state=${state}`, true); + expect(accepted.state).toEqual(expect.objectContaining({ nonce: url.searchParams.get("nonce"), code_verifier: expect.any(String) })); + await expect(client.readSigninResponseState(`rootline://auth/callback?code=x&state=${state}`, true)).rejects.toThrow(/state/i); + expect(browserWrite).not.toHaveBeenCalled(); + + const badNonceRequest = await client.createSigninRequest({ nonce: "expected-nonce" }); + const badNonceState = new URL(badNonceRequest.url).searchParams.get("state")!; + const jwt = [ + Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"), + Buffer.from(JSON.stringify({ sub: "alice", nonce: "wrong-nonce" })).toString("base64url"), + "signature", + ].join("."); + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + access_token: "access", token_type: "Bearer", expires_in: 300, id_token: jwt, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + await expect(client.processSigninResponse(`rootline://auth/callback?code=x&state=${badNonceState}`)) + .rejects.toThrow(/nonce.*does not match/i); + vi.unstubAllGlobals(); + browserWrite.mockRestore(); + }); + + test("projects identity claims without exposing access, ID, or refresh tokens", () => { + const visible = projectUser({ + profile: { sub: "subject", name: "Alice", email: "alice@example.com", permissions: ["rootline:profiles:sync"] }, + access_token: "secret-access", + id_token: "secret-id", + refresh_token: "secret-refresh", + expired: false, + }); + expect(visible).toEqual({ sub: "subject", name: "Alice", email: "alice@example.com", permissions: ["rootline:profiles:sync"] }); + expect(JSON.stringify(visible)).not.toContain("secret-"); + }); + + test("replays outbox at start, sign-in, manual sync, and a debounced profile edit while offline failures stay non-blocking", async () => { + vi.useFakeTimers(); + const sync = vi.fn() + .mockRejectedValueOnce(new TypeError("offline")) + .mockResolvedValue(undefined); + const auth = { sync } as unknown as AuthController; + const coordinator = new ProfileSyncCoordinator(auth, 750); + await expect(coordinator.start()).resolves.toBeUndefined(); + await coordinator.signedIn(); + await coordinator.manual(); + coordinator.profileEdited(); + coordinator.profileEdited(); + await vi.advanceTimersByTimeAsync(750); + expect(sync).toHaveBeenCalledTimes(4); + vi.useRealTimers(); + }); + + test("initializes once, serializes callbacks, and preserves a valid session after duplicate delivery", async () => { + const storedUser = { + profile: { sub: "alice", name: "Alice", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + let callbacksInFlight = 0; + let maxCallbacksInFlight = 0; + const manager = { + getUser: vi.fn(async () => storedUser), + signinRedirectCallback: vi.fn(async () => { + callbacksInFlight += 1; + maxCallbacksInFlight = Math.max(maxCallbacksInFlight, callbacksInFlight); + await Promise.resolve(); + callbacksInFlight -= 1; + if (manager.signinRedirectCallback.mock.calls.length > 1) throw new Error("state already consumed"); + return storedUser; + }), + signinRedirect: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + tauriMocks.invoke.mockResolvedValue({}); + const auth = new DesktopAuthController(config, manager as never); + await Promise.all([auth.initialize(), auth.initialize()]); + expect(manager.getUser).toHaveBeenCalledTimes(1); + expect(tauriMocks.onOpenUrl).toHaveBeenCalledTimes(1); + expect(tauriMocks.getCurrent).toHaveBeenCalledTimes(1); + expect(tauriMocks.listen).not.toHaveBeenCalled(); + + await Promise.all([ + auth.handleCallback("rootline://auth/callback?code=first&state=one"), + auth.handleCallback("rootline://auth/callback?code=first&state=one"), + ]); + expect(maxCallbacksInFlight).toBe(1); + expect(auth.snapshot().user).toEqual(expect.objectContaining({ sub: "alice" })); + expect(manager.signinRedirectCallback).toHaveBeenCalledTimes(1); + expect(auth.snapshot().error).toBeUndefined(); + }); + + test("installs ingress before vault loading and consumes a cached cold-start callback exactly once", async () => { + const callback = "rootline://auth/callback?code=cold&state=cold-state"; + let releaseUser!: () => void; + const userGate = new Promise((resolve) => { releaseUser = resolve; }); + const storedUser = { + profile: { sub: "cold-user", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + let consumed = false; + const manager = { + getUser: vi.fn(async () => { await userGate; return storedUser; }), + signinRedirectCallback: vi.fn(async () => { + if (consumed) throw new Error("state already consumed"); + consumed = true; + return storedUser; + }), + signinRedirect: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + let liveHandler!: (urls: string[]) => void; + tauriMocks.onOpenUrl.mockImplementation(async (handler: (urls: string[]) => void) => { + liveHandler = handler; + return vi.fn(); + }); + tauriMocks.getCurrent.mockResolvedValue([callback]); + tauriMocks.invoke.mockResolvedValue({}); + const auth = new DesktopAuthController(config, manager as never); + const initialization = auth.initialize(); + await vi.waitFor(() => expect(tauriMocks.onOpenUrl).toHaveBeenCalledTimes(1)); + expect(manager.getUser).not.toHaveBeenCalled(); + liveHandler([callback]); + releaseUser(); + await initialization; + await vi.waitFor(() => expect(manager.signinRedirectCallback).toHaveBeenCalledTimes(1)); + expect(consumed).toBe(true); + expect(auth.snapshot().user).toEqual(expect.objectContaining({ sub: "cold-user" })); + expect(auth.snapshot().error).toBeUndefined(); + }); + + test("surfaces vault/startup and browser launch failures without leaving the offline UI loading", async () => { + const manager = { + getUser: vi.fn(async () => { throw new Error("OS credential unavailable"); }), + signinRedirect: vi.fn(async () => { throw new Error("system browser unavailable"); }), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + const auth = new DesktopAuthController(config, manager as never); + await expect(auth.initialize()).rejects.toThrow(/credential unavailable/); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, user: null, error: "OS credential unavailable" })); + await expect(auth.signIn()).rejects.toThrow(/browser unavailable/); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, user: null, error: "system browser unavailable" })); + }); + + test("lets an abandoned browser sign-in time out, cancel, and retry without a stuck loading state", async () => { + vi.useFakeTimers(); + const stateStore = { + getAllKeys: vi.fn(async () => ["pending-state"]), + remove: vi.fn(async () => "stored-request"), + }; + const manager = { + settings: { stateStore }, + getUser: vi.fn(async () => null), + signinRedirect: vi.fn(async () => undefined), + signinRedirectCallback: vi.fn(), + clearStaleState: vi.fn(async () => undefined), + revokeTokens: vi.fn(), + removeUser: vi.fn(), + }; + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + + await auth.signIn(); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, signInPending: true })); + await auth.cancelSignIn(); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, signInPending: false })); + expect(stateStore.remove).toHaveBeenCalledWith("pending-state"); + expect(manager.clearStaleState).not.toHaveBeenCalled(); + + let releaseTimeoutCleanup!: () => void; + const timeoutCleanup = new Promise((resolve) => { + releaseTimeoutCleanup = () => resolve("stored-request"); + }); + stateStore.remove.mockImplementationOnce(() => timeoutCleanup); + await auth.signIn(); + await vi.advanceTimersByTimeAsync(OIDC_BROWSER_FLOW_TIMEOUT_MS); + await vi.waitFor(() => expect(stateStore.remove).toHaveBeenCalledTimes(2)); + expect(auth.snapshot()).toEqual(expect.objectContaining({ + loading: false, + signInPending: false, + error: "AUTH_SIGNIN_TIMEOUT", + })); + const retry = auth.signIn(); + await Promise.resolve(); + expect(manager.signinRedirect).toHaveBeenCalledTimes(2); + releaseTimeoutCleanup(); + await retry; + expect(manager.signinRedirect).toHaveBeenCalledTimes(3); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, signInPending: true })); + expect(auth.snapshot().error).toBeUndefined(); + auth.dispose(); + vi.useRealTimers(); + }); + + test("does not publish or retain a callback already in flight when sign-in is cancelled", async () => { + const stateStore = { getAllKeys: vi.fn(async () => []), remove: vi.fn() }; + const callbackUser = { + profile: { sub: "cancelled-user", permissions: ["rootline:profiles:sync"] }, + access_token: "cancelled-access", + expired: false, + }; + let releaseCallback!: (user: typeof callbackUser) => void; + const callbackResult = new Promise((resolve) => { releaseCallback = resolve; }); + const manager = { + settings: { stateStore }, + getUser: vi.fn(async () => null), + signinRedirect: vi.fn(async () => undefined), + signinRedirectCallback: vi.fn(() => callbackResult), + clearStaleState: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(async () => undefined), + }; + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.signIn(); + const callback = auth.handleCallback("rootline://auth/callback?code=cancelled&state=cancelled-state"); + await vi.waitFor(() => expect(manager.signinRedirectCallback).toHaveBeenCalledTimes(1)); + await auth.cancelSignIn(); + releaseCallback(callbackUser); + await callback; + + expect(auth.snapshot().user).toBeNull(); + expect(manager.removeUser).toHaveBeenCalledTimes(1); + expect(tauriMocks.invoke).not.toHaveBeenCalledWith("sync_hosted_profiles", expect.anything()); + }); + + test("does not publish or retain a callback already in flight when browser sign-in times out", async () => { + vi.useFakeTimers(); + const stateStore = { getAllKeys: vi.fn(async () => []), remove: vi.fn() }; + const callbackUser = { + profile: { sub: "timed-out-user", permissions: ["rootline:profiles:sync"] }, + access_token: "timed-out-access", + expired: false, + }; + let releaseCallback!: (user: typeof callbackUser) => void; + const callbackResult = new Promise((resolve) => { releaseCallback = resolve; }); + const manager = { + settings: { stateStore }, + getUser: vi.fn(async () => null), + signinRedirect: vi.fn(async () => undefined), + signinRedirectCallback: vi.fn(() => callbackResult), + clearStaleState: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(async () => undefined), + }; + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.signIn(); + const callback = auth.handleCallback("rootline://auth/callback?code=timeout&state=timeout-state"); + await vi.waitFor(() => expect(manager.signinRedirectCallback).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(OIDC_BROWSER_FLOW_TIMEOUT_MS); + releaseCallback(callbackUser); + await callback; + + expect(auth.snapshot()).toEqual(expect.objectContaining({ user: null, error: "AUTH_SIGNIN_TIMEOUT" })); + expect(manager.removeUser).toHaveBeenCalledTimes(1); + expect(tauriMocks.invoke).not.toHaveBeenCalledWith("sync_hosted_profiles", expect.anything()); + auth.dispose(); + vi.useRealTimers(); + }); + + test("cleans a partial listener registration before retrying initialization", async () => { + const firstDeepLinkUnlisten = vi.fn(); + const secondDeepLinkUnlisten = vi.fn(); + tauriMocks.onOpenUrl + .mockResolvedValueOnce(firstDeepLinkUnlisten) + .mockResolvedValueOnce(secondDeepLinkUnlisten); + tauriMocks.getCurrent + .mockRejectedValueOnce(new Error("cached deep links unavailable")) + .mockResolvedValueOnce(null); + const manager = { + getUser: vi.fn(async () => null), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + const auth = new DesktopAuthController(config, manager as never); + await expect(auth.initialize()).rejects.toThrow(/cached deep links unavailable/); + expect(firstDeepLinkUnlisten).toHaveBeenCalledTimes(1); + await auth.initialize(); + expect(tauriMocks.onOpenUrl).toHaveBeenCalledTimes(2); + expect(tauriMocks.getCurrent).toHaveBeenCalledTimes(2); + auth.dispose(); + expect(secondDeepLinkUnlisten).toHaveBeenCalledTimes(1); + }); + + test("commits account consent before scheduling best-effort hosted synchronization", async () => { + const storedUser = { + profile: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + const manager = { + getUser: vi.fn(async () => storedUser), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + let releaseSync!: () => void; + const pendingSync = new Promise((resolve) => { releaseSync = resolve; }); + tauriMocks.invoke.mockImplementation(async (command: string) => { + if (command === "sync_hosted_profiles") await pendingSync; + return {}; + }); + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.resolveAccountClaim(true); + expect(tauriMocks.invoke).toHaveBeenCalledWith("claim_hosted_account", { subject: "alice", uploadExisting: true }); + expect(auth.snapshot().accountClaimRequired).toBeFalsy(); + expect(tauriMocks.invoke).toHaveBeenCalledWith("sync_hosted_profiles", expect.anything()); + releaseSync(); + }); + + test("surfaces when epoch adoption preserves explicitly consented unclaimed mutations", async () => { + const storedUser = { + profile: { sub: "device-two", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + const manager = { + getUser: vi.fn(async () => storedUser), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + tauriMocks.invoke.mockRejectedValue({ + code: "RESET_REQUIRED", + message: "Existing hosted account epoch found.", + details: { + epoch: "00000000-0000-4000-8000-000000000123", + preservesConsentedOutbox: true, + }, + }); + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await expect(auth.sync()).rejects.toEqual(expect.objectContaining({ code: "RESET_REQUIRED" })); + expect(auth.snapshot()).toEqual(expect.objectContaining({ + epochResetRequired: true, + epochResetPreservesConsentedOutbox: true, + })); + }); + + test("publishes an actionable count when native sync quarantines invalid legacy mutations", async () => { + const storedUser = { + profile: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + const manager = { + getUser: vi.fn(async () => storedUser), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), clearStaleState: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + tauriMocks.invoke.mockResolvedValue({ quarantinedMutations: 2 }); + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.sync(); + expect(auth.snapshot()).toEqual(expect.objectContaining({ quarantinedMutations: 2 })); + }); + + test("clears quarantine status when hosted deletion also removes local profiles", async () => { + const storedUser = { + profile: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + const manager = { + getUser: vi.fn(async () => storedUser), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + tauriMocks.invoke.mockImplementation(async (command: string) => + command === "sync_hosted_profiles" ? { quarantinedMutations: 2 } : undefined); + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.sync(); + + await auth.deleteAccountData(true); + + expect(auth.snapshot().quarantinedMutations).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/test/setup.ts b/apps/desktop/src/test/setup.ts new file mode 100644 index 0000000..623b10f --- /dev/null +++ b/apps/desktop/src/test/setup.ts @@ -0,0 +1,23 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + }), +}); + +Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { + value: () => null, +}); diff --git a/apps/desktop/src/test/stronghold-storage.test.ts b/apps/desktop/src/test/stronghold-storage.test.ts new file mode 100644 index 0000000..99a1953 --- /dev/null +++ b/apps/desktop/src/test/stronghold-storage.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + load: vi.fn(), + save: vi.fn(), + values: new Map(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/path", () => ({ + appDataDir: vi.fn(async () => "/protected/app-data"), + join: vi.fn(async (...parts: string[]) => parts.join("/")), +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); +vi.mock("@tauri-apps/plugin-deep-link", () => ({ getCurrent: vi.fn(), onOpenUrl: vi.fn() })); +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); +vi.mock("@tauri-apps/plugin-stronghold", () => ({ + Stronghold: { load: mocks.load }, +})); + +import { StrongholdAsyncStorage } from "../auth"; + +beforeEach(() => { + mocks.values.clear(); + mocks.invoke.mockReset().mockResolvedValue("a".repeat(64)); + mocks.save.mockReset().mockResolvedValue(undefined); + const store = { + get: vi.fn(async (key: string) => mocks.values.get(key) ?? null), + insert: vi.fn(async (key: string, value: number[]) => { mocks.values.set(key, Uint8Array.from(value)); }), + remove: vi.fn(async (key: string) => { mocks.values.delete(key); return null; }), + }; + const client = { getStore: () => store }; + mocks.load.mockReset().mockResolvedValue({ + loadClient: vi.fn(async () => { throw new Error("first install"); }), + createClient: vi.fn(async () => client), + save: mocks.save, + }); +}); + +test("persists OIDC state and tokens only through the OS-keyed Stronghold store", async () => { + const browserWrite = vi.spyOn(Storage.prototype, "setItem"); + const storage = new StrongholdAsyncStorage(); + await storage.setItem("oidc.user", JSON.stringify({ access_token: "secret-token" })); + expect(mocks.invoke).toHaveBeenCalledWith("auth_vault_password"); + expect(mocks.load).toHaveBeenCalledWith("/protected/app-data/rootline-auth.stronghold", "a".repeat(64)); + expect(await storage.getItem("oidc.user")).toContain("secret-token"); + expect(browserWrite).not.toHaveBeenCalled(); + expect(mocks.save).toHaveBeenCalled(); + browserWrite.mockRestore(); +}); + +test("fails closed when the native OS credential boundary cannot return the existing vault key", async () => { + const browserWrite = vi.spyOn(Storage.prototype, "setItem"); + mocks.invoke.mockRejectedValueOnce(new Error("Stronghold vault exists but its OS-protected key is missing.")); + const storage = new StrongholdAsyncStorage(); + await expect(storage.setItem("oidc.user", "must-not-persist")).rejects.toThrow(/OS-protected key is missing/); + expect(mocks.load).not.toHaveBeenCalled(); + expect(browserWrite).not.toHaveBeenCalled(); + browserWrite.mockRestore(); +}); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..eb7a6f5 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src"] +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts new file mode 100644 index 0000000..dc4ec91 --- /dev/null +++ b/apps/desktop/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { port: 1420, strictPort: true }, +}); diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 0000000..444f151 --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + }, +}); diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..72300c9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,28 @@ +# Rootline documentation + +Rootline by baole.space is a local-first, additive directory-structure synchronizer with a Node CLI, a Tauri desktop app, and optional hosted profile synchronization. + +## Users + +- [Configuration](configuration.md) - CLI flags, JSON configuration, desktop profiles, and hosted-sync build configuration. +- [Migrate from npm 1.1.0](migration-v1-to-v2.md) - Behavior changes and a safe migration sequence. +- [Privacy](privacy.md) - Exact local, hosted, and telemetry boundaries. +- [Security policy](../SECURITY.md) - Supported versions, reporting, and trust boundaries. + +## Operators and releasers + +- [Architecture](architecture.md) - Components, data ownership, and trust boundaries. +- [Hosted profile sync](hosted-profile-sync.md) - Authentik, API contract, reset semantics, and provider setup. +- [Operations](operations.md) - API/PostgreSQL deployment, migrations, health, backups, and incidents. +- [Release process](release.md) - CI matrix and fail-closed npm/API/desktop stable gates. + +## Contributors + +- [Contributing](../CONTRIBUTING.md) - Local setup, tests, and pull request workflow. +- [Rootline Desktop + CLI v2 implementation plan](superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - Approved product constraints. +- [npm 1.1.0 recovery](baseline/npm-1.1.0-recovery.md) - Published baseline and integrity evidence. + +## Related + +- [Rootline README](../README.md) - Product overview and supported installation paths. +- [Release process](release.md) - Distribution status and external gates. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..04bf20a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,45 @@ +# Rootline architecture + +## Components + +| Component | Ownership | +|---|---| +| `packages/contracts` | Private workspace package exporting stable domain, cloud, error, and DTO contracts | +| `packages/core` | Environment-independent snapshot, exclusion, planning, selection, and validation logic | +| `packages/cli` | Node filesystem adapter and the public `folder-sync` binary | +| `apps/desktop` | React workflow plus the Tauri 2 native boundary | +| `apps/api` | Optional NestJS/Prisma hosted profile synchronization | + +The npm CLI bundles its private workspace implementation into one public tarball. Desktop filesystem access, dialogs, SQLite, device identity, outbox, tokens, and remote sync live behind the Rust/native boundary. React receives projected profile and workflow state, not bearer tokens. + +## Local synchronization + +```text +source scan -> deterministic snapshot -> additive plan -> user selection + -> source/target revalidation -> ordered mkdir operations -> local history +``` + +The source is read-only. Rootline creates missing target directories and never copies files or deletes files/directories. Equal, ancestor, descendant, symlink, and Windows junction relationships are rejected. Excluded subtrees and directories containing `.ignore` are pruned. A stale source or target invalidates the plan before mutation. + +## Desktop persistence + +SQLite owns profiles, device identity, capped run history, account binding, mutation outbox, receipts, cursor, epoch, and lifecycle generations. Migrations are append-only and applied by the native process. Sign-out, account deletion, account switching, and epoch adoption rotate lifecycle state so an in-flight response cannot rebind or resurrect stale data. + +OIDC protocol state and tokens use Stronghold. Its per-install vault secret uses the operating-system credential manager. Tokens are not written to localStorage, SQLite, logs, or React state. + +## Hosted profile sync + +Hosted sync is optional and never needed for scanning or applying locally. Only an explicitly saved complete profile is eligible for upload after account consent. A profile includes its name, absolute source/target paths, exclusions, timestamps, and additive mode. Directory trees, file names/content, and run history are outside the cloud contract. + +The API validates static Authentik RS256 keys, exact issuer/audience, the verified subject, and `rootline:profiles:sync`. Tenant ownership cannot be selected in a request body. Server commit-arrival order is last-write-wins; deletes are tombstones; idempotent mutation receipts are content-bound. Account-data deletion rotates the epoch and stale devices must explicitly adopt the new epoch. + +## Distribution trust boundary + +Pull requests build code but never publish. Stable release workflows require exact `2.0.0` refs/confirmations, protected production environments, immutable versions, and complete credentials. npm uses provenance; the API migrates before deployment and must pass HTTPS health; desktop installers use Apple Developer ID/notarization or Windows Authenticode and every updater archive has a Tauri signature. No release path disables signatures. + +## Related + +- [Configuration](configuration.md) - Runtime and build-time settings. +- [Privacy](privacy.md) - Data inventory and retention. +- [Operations](operations.md) - Hosted deployment responsibilities. +- [Release process](release.md) - CI and distribution gates. diff --git a/docs/baseline/folder-structure-sync-1.1.0.tgz b/docs/baseline/folder-structure-sync-1.1.0.tgz new file mode 100644 index 0000000..102b3ae Binary files /dev/null and b/docs/baseline/folder-structure-sync-1.1.0.tgz differ diff --git a/docs/baseline/npm-1.1.0-recovery.md b/docs/baseline/npm-1.1.0-recovery.md new file mode 100644 index 0000000..44395b0 --- /dev/null +++ b/docs/baseline/npm-1.1.0-recovery.md @@ -0,0 +1,50 @@ +# npm `folder-structure-sync@1.1.0` recovery + +The checked-in archive [folder-structure-sync-1.1.0.tgz](./folder-structure-sync-1.1.0.tgz) is the byte-for-byte tarball recovered from the public npm registry on 2026-08-15. It is retained as migration evidence only; it is not a workspace package or a future release entry point. + +## Registry provenance and integrity + +The registry metadata returned the following values: + +| Field | Value | +| --- | --- | +| Tarball | `https://registry.npmjs.org/folder-structure-sync/-/folder-structure-sync-1.1.0.tgz` | +| `dist.integrity` | `sha512-DMLwBKls8g/9ZSx2iSTW6ChOkuZSFjoe8cnn3k1NO7oM0D0sRNGXy2GuipFqhtukoUGwI1YdfENsHfzCZTjY1w==` | +| `dist.shasum` | `5cbc1470b492f36bad11653f2b8545a62daf5929` | +| `gitHead` | `a9cb35279f65db6e939c884a0503f2e13b3a5d93` | + +The downloaded archive verified with both commands: + +```text +$ shasum -a 1 docs/baseline/folder-structure-sync-1.1.0.tgz +5cbc1470b492f36bad11653f2b8545a62daf5929 docs/baseline/folder-structure-sync-1.1.0.tgz + +$ openssl dgst -sha512 -binary docs/baseline/folder-structure-sync-1.1.0.tgz | openssl base64 -A +DMLwBKls8g/9ZSx2iSTW6ChOkuZSFjoe8cnn3k1NO7oM0D0sRNGXy2GuipFqhtukoUGwI1YdfENsHfzCZTjY1w== +``` + +## Unreachable original Git head + +The registry records `a9cb35279f65db6e939c884a0503f2e13b3a5d93` as the publish head, but it is not present in this clone: + +```text +$ git cat-file -e a9cb35279f65db6e939c884a0503f2e13b3a5d93^{commit} +fatal: Not a valid object name a9cb35279f65db6e939c884a0503f2e13b3a5d93^{commit} + +$ git branch -a --contains a9cb35279f65db6e939c884a0503f2e13b3a5d93 +error: no such commit a9cb35279f65db6e939c884a0503f2e13b3a5d93 +``` + +This recovery is a new commit and does not amend, reset, or otherwise rewrite repository history. + +## Legacy behavior retained for migration + +The complete published source remains available inside the checked-in archive. The existing root `index.js` and `sync-config.json` are intentionally left untouched as the pre-workspace migration evidence. + +In the recovered `1.1.0` source, recursive scanning checks for a `.ignore` file before reading a directory. Its presence stops scanning that directory and all of its descendants; a nested directory has already been discovered by its parent, so the marker prunes children rather than the nested directory itself. Future core work must preserve that pruning semantics deliberately, rather than changing legacy history to retrofit it. + +## Related + +- [Rootline documentation](../README.md) - Documentation navigation. +- [Migration to 2.0.0](../migration-v1-to-v2.md) - Safe user upgrade sequence. +- [Rootline Desktop + CLI v2 implementation plan](../superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - The migration plan this evidence supports. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..59b99f4 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,58 @@ +# Rootline configuration + +## CLI + +Rootline requires Node.js 20 or newer. The command is: + +```text +folder-sync [--dry-run] [--verbose] [--auto] [--config PATH] [--json] +``` + +Configuration resolution is deliberately narrow: an explicit `--config` path wins; otherwise Rootline reads `sync-config.json` only from the current working directory; if that file is absent, built-in exclusions and operating-system case behavior apply. Invalid explicit files fail—there is no silent fallback. + +```json +{ + "defaultExclusions": [".git", "node_modules", ".DS_Store", "dist", "build"], + "customExclusions": ["private-cache", "*.generated"], + "targetCaseSensitive": false +} +``` + +`defaultExclusions` replaces the built-in list when present. `customExclusions` is appended. Patterns without `/` match a basename at any depth; patterns with `/` match a complete POSIX relative path. `*`, `?`, and `**` are supported and a match prunes the complete subtree. Rootline detects the target filesystem's case behavior without writing probe files; `targetCaseSensitive` is an explicit override for unusual or unavailable platform metadata. + +`--dry-run` never creates a missing target. `--json` is valid only with `--dry-run` or `--auto` and never prompts. Use `--auto --json` for non-interactive application, and treat exit codes `1` and `2` as failures. + +## Desktop profiles + +A saved profile contains a display name, absolute source and target paths, exclusions, timestamps, and additive sync mode. Names contain 1–80 characters, each path 1–4096 characters, and the exclusion list at most 100 patterns of 1–256 characters. It is local by default. Changing a folder outside Rootline may require re-binding the profile before a new scan. The desktop always supports offline profiles and run history without authentication. Removing a local profile does not remove its run history. + +## Optional hosted sync build variables + +Set either all three values or none: + +```dotenv +VITE_AUTHENTIK_ISSUER=https://auth.example.com/application/o/rootline/ +VITE_AUTHENTIK_CLIENT_ID=rootline-desktop +VITE_ROOTLINE_SYNC_API=https://rootline-api.example.com +``` + +Both URLs must use HTTPS. With no values, account controls are absent and offline behavior is unchanged. A partial or insecure configuration stops startup with an actionable error. The Authentik client is public: never add a client secret. + +## API environment + +```dotenv +DATABASE_URL=postgresql://rootline:REDACTED@postgres.example.com:5432/rootline?sslmode=require&sslaccept=strict +JWT_ISSUER=https://auth.example.com/application/o/rootline/ +JWT_AUDIENCE=rootline-desktop +JWT_JWKS_PATH=/run/secrets/rootline-authentik-jwks.json +RATE_LIMIT_PER_MINUTE=60 +PORT=3000 +``` + +All first four values are required. `JWT_JWKS_PATH` must be a mounted JSON Web Key Set containing at least one Authentik RS256 public key. Production database connections require `sslmode=require&sslaccept=strict` so Prisma verifies the server certificate. Secret values belong in the deployment provider, not environment files committed to git. + +## Related + +- [Architecture](architecture.md) - How configuration crosses trust boundaries. +- [Hosted profile sync](hosted-profile-sync.md) - Exact Authentik registration and API contract. +- [Operations](operations.md) - Production secret and migration handling. diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md new file mode 100644 index 0000000..16bacb8 --- /dev/null +++ b/docs/hosted-profile-sync.md @@ -0,0 +1,114 @@ +# Hosted profile sync operations + +Rootline's hosted sync is optional. An unconfigured desktop remains fully local; a partially configured desktop fails closed instead of attempting authentication. The API also refuses to start until its database and token-verification inputs are complete. + +Only saved profile documents are uploaded. A profile contains its name, absolute source and target paths, exclusions, timestamps, and additive sync mode. Directory trees, files, file contents, and run history never enter the hosted outbox. + +## Authentik registration + +Production registration is an external deployment gate. Create an Authentik OAuth2/OIDC provider and application with these exact properties: + +| Setting | Required value | +|---------|----------------| +| **Application slug** | `rootline` | +| **Client type** | Public | +| **Grant** | Authorization Code with PKCE | +| **Redirect URI** | `rootline://auth/callback` | +| **Scopes** | `openid profile email permissions offline_access` | +| **Audience** | The value deployed as `JWT_AUDIENCE` | +| **Permission claim** | `rootline:profiles:sync` in the `permissions` array | +| **Signing algorithm** | RS256 | + +Do not issue or embed a client secret. The desktop opens the system browser and validates the exact callback scheme, host, path, state, PKCE verifier, and OIDC nonce before accepting a session. A browser flow left unfinished returns to a cancelable, retryable state after five minutes instead of leaving the desktop busy indefinitely. The Tauri deep-link listener is installed before vault loading, cached cold-start URLs are read with `getCurrent`, and each callback state is consumed once. The deep-link plugin is the only URL delivery path, including Windows single-instance forwarding. OIDC state and tokens use Tauri Stronghold; the per-install random vault password is stored in the operating-system credential manager, never browser storage or React component state. + +Set all three desktop build variables together: + +```dotenv +VITE_AUTHENTIK_ISSUER=https://auth.example.com/application/o/rootline/ +VITE_AUTHENTIK_CLIENT_ID=rootline-desktop +VITE_ROOTLINE_SYNC_API=https://rootline-api.example.com +``` + +When all are absent, account controls are disabled and offline use continues. If only some are present, or either endpoint is not HTTPS, startup fails with an actionable configuration error. + +Profiles saved before the first sign-in remain unclaimed. Because they contain absolute paths, Rootline requires an explicit **Upload existing profiles** or **Keep local only** decision before binding their outbox to an OIDC subject. Signing out always removes that subject's cursor and queued mutations; choosing to keep local profiles does not make them eligible for a later account automatically. A different account therefore cannot inherit the previous account's paths or cursor. + +If a second device explicitly consents to upload pre-login profiles but discovers an existing server epoch, accepting that epoch preserves only those consented, not-yet-cloud-owned mutation chains. Each mutation keeps that provenance until its own successful receipt. An edit or deletion of the same consented profile before adoption inherits the marker, preserving order and preventing an older upsert from overwriting or resurrecting it; unrelated edits queued after account binding are cloud-owned and are discarded by a later reset. Once every consented chain is acknowledged, later reset adoption clears the outbox so deleted hosted data cannot be resurrected. + +Native sync calls are serialized. Each request captures the verified subject plus the local epoch, starting cursor, mutation generation, and a random lifecycle generation. Before opening a SQLite transaction, a successful response must contain exactly one receipt for every mutation sent—no duplicate, missing, or extra IDs. The response must still match the captured state inside the same transaction before any profile, receipt, or cursor is applied. Sign-out, account changes, and epoch acceptance rotate the lifecycle generation; local profile save/delete changes only the mutation generation. After receipts are removed, a response also skips any profile or tombstone that still has a pending local mutation in a later batch. Every follow-up request is constructed under one database critical section that requires the captured lifecycle and prohibits auto-binding; lifecycle is checked again immediately after transport, before interpreting even a reset response. The loop retries a rejected response only while that lifecycle remains current. This permits same-session local-edit replay while sign-out, removal, account switch, epoch adoption, and same-account ABA stop without rebuilding a stale request, rebinding the old subject, or reusing its captured token. Invalid legacy outbox mutations are removed from the FIFO transactionally and recorded in a metadata-only quarantine; the local profile remains available to edit and save again. Repeating an already committed claim for the same subject is idempotent. + +## API deployment + +The API requires: + +```dotenv +DATABASE_URL=postgresql://rootline:REDACTED@postgres.example.com:5432/rootline?sslmode=require&sslaccept=strict +JWT_ISSUER=https://auth.example.com/application/o/rootline/ +JWT_AUDIENCE=rootline-desktop +JWT_JWKS_PATH=/run/secrets/rootline-authentik-jwks.json +RATE_LIMIT_PER_MINUTE=60 +PORT=3000 +``` + +`JWT_JWKS_PATH` must be a deployment-mounted static JWKS containing the current Authentik RS256 public signing keys. Missing or empty files stop startup. Key rotation is an explicit rollout: mount a JWKS containing both accepted public keys, restart the API, rotate Authentik, then remove the retired key after all old access tokens expire. Never fetch keys dynamically from an untrusted token header. + +Provision encrypted PostgreSQL separately, then apply the checked-in migration before starting the API: + +```bash +pnpm --filter @rootline/api prisma:generate +pnpm --filter @rootline/api prisma:migrate:deploy +pnpm --filter @rootline/api start +``` + +The stable deployment must provide TLS termination for the API, TLS validation for PostgreSQL, encrypted backups, and a protected JWKS mount. Those production credentials and infrastructure are intentionally not committed to this repository. + +## Service contract and privacy limits + +| Endpoint | Contract | +|----------|----------| +| **`GET /healthz`** | Public liveness plus non-sensitive deployment build identity; no account data | +| **`POST /v1/sync`** | Authenticated profile mutations and cursor delta | +| **`DELETE /v1/account-data`** | Deletes hosted data and rotates the user's epoch | + +`POST /v1/sync` accepts the stable ecosystem v1 fields (`accountEpoch`, mutation `type`, and `SyncProfileV1`) as well as the desktop's paginated transport (`epoch`, mutation `kind`, receipts, records, and `hasMore`). A request must use one shape consistently. Responses contain both projections: `accountEpoch`, `acknowledgedMutationIds`, and `profiles` for the public contract, plus the paginated fields used by the desktop. The public projection preserves tombstone profile metadata so a delete delta includes `deletedAt`; account deletion still removes every profile and tombstone. + +Both account endpoints require an RS256 token with the configured issuer, audience, subject, and `rootline:profiles:sync` permission. Tenant ownership always comes from the verified `sub`; request bodies cannot select another tenant. Requests are limited to 256 KiB, 100 mutations, and 60 authenticated requests per user per rolling minute. Profile names contain 1–80 characters, source and target paths contain 1–4096 characters, and exclusions contain at most 100 patterns of 1–256 characters each. The shared contract, API DTO, desktop editor, and native persistence boundary enforce these same limits. + +The built-in rolling limiter is process-local. Run one API replica for this version. Horizontal scaling requires a shared, subject-keyed limiter before adding replicas; an ingress-only IP limit is not equivalent to the per-user contract. Every committed profile/tombstone also records the last device ID that submitted it, without logging profile paths. + +Server commit arrival order is last-write-wins. Every new mutation advances a per-user revision and deletes become tombstones. Mutation receipt rows are physically retained for 90 days and purged opportunistically during a later sync, while a compact content-bound deduplication record remains for the lifetime of the account epoch. Replaying the same mutation ID and payload after receipt expiry therefore acknowledges its original revision without another write; reusing the ID with different content returns 409. Account deletion clears profiles, tombstones, changes, receipts, and deduplication records, then rotates the epoch. A stale device receives `RESET_REQUIRED` and cannot silently resurrect deleted data; clients still recognize the pre-release `SYNC_EPOCH_RESET_REQUIRED` spelling during upgrades. + +Mutation IDs are bound to a canonical content hash; reuse with different content returns 409 instead of silently dropping a change. Delta pages contain at most 100 records and approximately 1 MiB of record JSON. `hasMore` and the returned cursor let the desktop drain long-offline deltas while enforcing a 2 MiB streaming response cap. + +Production logs must remain metadata-only: never log bearer tokens, request bodies, profile fields, or absolute paths. + +For a reproducible local integration run, Docker can provision a disposable PostgreSQL 16 instance, apply every real migration, execute the API e2e suite, and remove the instance automatically: + +```bash +pnpm --filter @rootline/api test:e2e:postgres +``` + +The root test command uses that same provision/migrate/test/cleanup harness automatically and needs no pre-existing `DATABASE_URL`: + +```bash +pnpm test +``` + +The API suite includes a real seam test that starts from the desktop's SQLite profile/outbox, adopts an existing device-one server epoch, reconnects through NestJS/PostgreSQL, verifies receipts, and proves the absolute path is not sent to a different OIDC subject. + +## Operator validation + +- [ ] Authentik registration is a public client with the exact redirect URI and scopes. +- [ ] The deployed static JWKS and Authentik signing keys agree. +- [ ] PostgreSQL connections and backups are encrypted. +- [ ] Database migrations completed before the API rollout. +- [ ] API ingress enforces HTTPS and the 256 KiB request limit is not raised upstream. +- [ ] Logs contain no tokens, request bodies, or absolute paths. +- [ ] Account deletion is tested with a second stale device and returns reset-required. + +## Related + +- [Rootline documentation](README.md) - Documentation navigation. +- [Configuration](configuration.md) - Desktop and API environment variables. +- [Operations](operations.md) - Production rollout, backups, and incident handling. +- [Rootline Desktop + CLI v2 implementation plan](superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - Product constraints and acceptance criteria. diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md new file mode 100644 index 0000000..c358f9d --- /dev/null +++ b/docs/migration-v1-to-v2.md @@ -0,0 +1,47 @@ +# Migrate from folder-structure-sync 1.1.0 to Rootline 2.0.0 + +## Before upgrading + +`2.0.0` keeps the npm package name `folder-structure-sync`, binary `folder-sync`, ISC license, `.ignore` subtree pruning, and the legacy `--dry-run`, `--verbose`, and `--auto` flags. It raises the minimum Node.js version to 20 and replaces the original interactive presentation with deterministic planning, explicit safety errors, JSON automation, and optional configuration paths. + +The stable release is not assumed to exist merely because this branch has version `2.0.0`. Confirm the version on [npm](https://www.npmjs.com/package/folder-structure-sync) before upgrading. + +## Safe migration + +1. Upgrade automation hosts to Node.js 20 or newer. +2. Preserve the current `sync-config.json` and verify it is strict JSON; comments are invalid. +3. Install `folder-structure-sync@2.0.0` only after npm shows that exact version. +4. Run the existing source/target pair with `--dry-run --json` and archive the output. +5. Resolve any `PATH_OVERLAP`, `INVALID_PATH`, or configuration error rather than bypassing it. +6. Run once interactively, or add `--auto --json` only after reviewing the plan. + +Example automation migration: + +```bash +folder-sync "$SOURCE" "$TARGET" --dry-run --json --config ./sync-config.json +folder-sync "$SOURCE" "$TARGET" --auto --json --config ./sync-config.json +``` + +## Behavior differences + +| Area | 1.1.0 | 2.0.0 | +|---|---|---| +| Node minimum | 12 | 20 | +| Missing target | Legacy creation behavior | Previewed or explicitly created | +| Root overlap/link aliases | Limited checks | Rejected before mutation | +| Automation output | Human-oriented | One JSON document with `--json` | +| Config selection | Working-directory file | Same default plus explicit `--config` | +| Desktop | None | Local-first macOS/Windows application | +| Cloud | None | Optional profile-only sync; CLI remains local-only | + +There is no v1 cloud or desktop database to migrate. Creating a desktop profile is an explicit local action. Profiles created before first sign-in remain unclaimed until the user chooses whether to upload them. + +## Rollback + +Keep a v1.1.0 lockfile or tarball reference during rollout. Because both versions only add directories, rolling the CLI binary back does not require undoing filesystem mutations. Do not delete directories to simulate rollback. The preserved tarball integrity and behavior evidence is in the baseline document. + +## Related + +- [npm 1.1.0 recovery](baseline/npm-1.1.0-recovery.md) - Integrity and unreachable-source evidence. +- [Configuration](configuration.md) - v2 flags and JSON schema. +- [Privacy](privacy.md) - Optional hosted profile implications. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..9624e30 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,57 @@ +# Rootline operations + +## Deployment order + +1. Provision encrypted PostgreSQL 16 with encrypted, tested backups. +2. Register the Authentik public client exactly as documented and mount a static RS256 JWKS. +3. Configure the API production environment without placing secrets in the image. +4. Build from the digest-pinned base and push a unique candidate image only if stable tag `rootline-api:2.0.0` does not already exist. +5. Apply every checked-in Prisma migration to the production database. +6. Deploy the immutable image through the configured HTTPS provider webhook. +7. Require `GET /healthz` to return `status: "ok"` and the unique build identity requested by the current workflow over HTTPS. +8. Promote that verified digest to stable tag `rootline-api:2.0.0`; never overwrite the stable tag. + +The release workflow enforces this order. A migration failure prevents deployment; a deployment or health failure prevents a successful stable release. + +## Local integration validation + +```bash +pnpm --filter @rootline/api test:e2e:postgres +``` + +This uses disposable PostgreSQL 16 and real migrations. For a manually managed test database: + +```bash +pnpm --filter @rootline/api prisma:generate +DATABASE_URL=postgresql://... pnpm --filter @rootline/api prisma:migrate:deploy +DATABASE_URL=postgresql://... pnpm --filter @rootline/api test:e2e +``` + +## Health and logs + +`/healthz` is public and contains only health state plus a non-sensitive deployment build identity; it contains no account data. Monitor non-2xx responses, migration failures, authentication failure rates, request-limit rejections, and PostgreSQL capacity. Do not add request-body or authorization-header logging during incident response. Absolute profile paths must remain absent from production logs. + +The built-in rate limiter is process-local. Run one API replica for this release. A shared subject-keyed limiter is required before horizontal scaling. + +## Backup and recovery + +- Encrypt database connections, storage, snapshots, and off-site backups. +- Restore backups into an isolated environment and validate migrations plus `/healthz` regularly. +- Treat a restore as hosted profile recovery only; filesystem trees and run history are not server data. +- Coordinate restore timestamps with epoch/account-deletion semantics so deleted accounts are not accidentally reintroduced. + +## Key rotation and incidents + +For Authentik signing-key rotation, mount a JWKS containing old and new public keys, restart, rotate the issuer, wait for old access tokens to expire, then remove the retired key. Never choose a JWKS URL from an unverified token header. + +If a deployment secret leaks, stop the release, rotate it at the provider, replace the repository environment secret, invalidate affected sessions when applicable, and only then re-run preflight. If the API becomes unhealthy after migration, do not rewrite or delete migration history; halt deployment and use a reviewed forward migration or provider rollback compatible with the applied schema. + +## External production gates + +The repository cannot complete Authentik registration, production PostgreSQL/TLS/backup provisioning, provider webhook setup, Apple notarization, Windows certificate issuance, or updater-key custody. Until operators configure and exercise these gates, stable distribution remains blocked and must not be described as live. + +## Related + +- [Hosted profile sync](hosted-profile-sync.md) - Authentik and protocol details. +- [Release process](release.md) - Workflow inputs and validation. +- [Privacy](privacy.md) - Operational data limits. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..809a351 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,35 @@ +# Rootline privacy + +Rootline is local-first and has no usage telemetry. + +## Data inventory + +| Data | Local desktop | Hosted API | Product telemetry | +|---|---:|---:|---:| +| Saved profile name and absolute source/target paths | Yes | Only after sign-in and explicit consent | Never | +| Profile exclusions and timestamps | Yes | Only with the saved profile | Never | +| Directory tree, file names, or file contents | Used transiently for local scans | Never | Never | +| Run history and selected plan | Yes | Never | Never | +| Device ID, cursor, epoch, mutation receipts | Yes | Protocol-scoped values only | Never | +| OIDC tokens/protocol state | Native encrypted vault | Token verified in memory | Never | + +The CLI has no cloud profile feature and sends no product data. The desktop works without an account. Hosted sync is a convenience for complete saved profile documents, not a backup of filesystem content. + +## Consent and account separation + +Profiles made before sign-in are not silently claimed. The user chooses whether to upload existing profiles or keep them local. Account switching clears the previous subject's cursor and queued mutations. Keeping local data on sign-out does not make it eligible for a later account automatically. + +Because profiles contain absolute paths, they can reveal usernames, drive layouts, organization names, or project names. Use hosted sync only when that disclosure is appropriate. The API derives tenant ownership from the verified OIDC subject and never accepts a tenant selector from the client. + +## Retention and deletion + +Local profiles remain until the user deletes them or chooses local-profile removal during sign-out/account removal. Deleting local profiles does not delete the capped run history; run history remains local and ages out only through its bounded-history policy. Hosted mutation receipts are physically retained for 90 days. Expired receipt rows are eligible for cleanup after 90 days and are purged opportunistically on a later sync. A compact content-bound deduplication record remains for the lifetime of the account epoch so replaying an expired receipt cannot create another revision. Deleting hosted account data removes profiles, tombstones, changes, receipts, and that deduplication record, then rotates the epoch so a stale device cannot silently restore them. + +Production logs are metadata-only. Operators must not log bearer tokens, request bodies, profile fields, or absolute paths. Backups containing hosted profiles must be encrypted and governed by the deployment's retention policy. + +## Related + +- [Security policy](../SECURITY.md) - Vulnerability handling and enforcement boundaries. +- [Architecture](architecture.md) - Local/native/API ownership. +- [Hosted profile sync](hosted-profile-sync.md) - Account deletion and reset protocol. +- [Release process](release.md) - Distribution gates that preserve these boundaries. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..9f56ce7 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,68 @@ +# Rootline 2.0.0 release process + +Stable releases are performed only by the checked-in GitHub Actions workflows. Local validation never publishes, deploys, signs, or creates a GitHub release. + +## Pull request CI + +`.github/workflows/ci.yml` runs: + +- ESLint, TypeScript typecheck, unit tests, builds, workflow contract tests; +- isolated npm tarball install and `folder-sync` execution; +- real PostgreSQL 16 migration/API/desktop seam integration; +- Rust `fmt`, `clippy -D warnings`, and tests; +- Tauri compile matrices for macOS Universal, Windows x64, and Windows ARM64. + +Windows ARM64 uses GitHub's native `windows-11-arm` runner. CI uses `--no-bundle` for compile coverage; it does not create an unsigned installer. + +Validate workflow syntax/contracts without publishing: + +```bash +pnpm validate:workflows +pnpm test:release +``` + +## Signed candidate and public beta + +Run `release-desktop-candidate.yml` manually from reviewed `master` with confirmation `build-rootline-candidate`. The protected `desktop-production` environment and the same Apple, Windows, updater, Authentik, and API values used by stable release are mandatory, so candidate testing exercises the real signing and hosted-profile configuration. + +The default `internal` channel uploads signed/notarized workflow artifacts only. After internal QA passes, rerun with channel `public-beta` and a new tag matching `v2.0.0-beta.N`; the workflow builds that SemVer prerelease, verifies every platform signature, and creates a GitHub prerelease. It does not publish `latest.json` or change the stable updater channel. A beta installation can upgrade after the signed `2.0.0` stable updater manifest is published. + +Use this sequence before stable release: internal signed candidate, public beta, then the API → npm → desktop stable train. Never promote an artifact from an unreviewed branch or reuse an existing beta tag. + +## npm + +`release-npm.yml` publishes only the public `folder-structure-sync` package. It runs only from the existing `v2.0.0` tag (with an additional exact confirmation for manual runs), checks the package version, and stops if the protected `npm-production` environment secret `NPM_TOKEN` is missing. An unprivileged job reruns quality/unit gates, installs the packed CLI in isolation, and uploads the exact tarball plus an independent checksum output. A minimal protected `npm-production` job verifies that checksum and packed identity, then exposes `NPM_TOKEN` only to `npm publish` with provenance and public access. `@rootline/core` and `@rootline/contracts` remain private workspace packages bundled into the CLI; they are never published independently. + +Required external gate: protected `npm-production` environment secret `NPM_TOKEN` with publish access, plus approval for that environment. Never configure this credential as a repository secret. + +## API + +`release-api.yml` requires the existing `v2.0.0` tag, an exact manual confirmation, and every production database/auth/deployment secret. It uses a digest-pinned Node base and refuses to run if GHCR tag `rootline-api:2.0.0` already exists. It pushes a unique candidate tag, applies the checked-in migrations, then calls the HTTPS deployment webhook with the resulting immutable digest and unique workflow build identity. It polls HTTPS `/healthz` until that exact identity is live. Only after health succeeds does it promote that verified digest to `rootline-api:2.0.0`; a failed attempt leaves stable identity unused and safely retryable. A healthy response from an older replica cannot pass the gate. + +Required external gates: `ROOTLINE_API_DATABASE_URL`, `ROOTLINE_API_DEPLOY_WEBHOOK_URL`, `ROOTLINE_API_DEPLOY_TOKEN`, `ROOTLINE_API_BASE_URL`, `ROOTLINE_JWT_ISSUER`, `ROOTLINE_JWT_AUDIENCE`, `ROOTLINE_JWT_JWKS_B64`, GHCR permissions, and `api-production` approval. PostgreSQL TLS/backup and provider runtime configuration are operator responsibilities. + +## Desktop + +`release-desktop.yml` runs only from the existing `v2.0.0` tag. Before any platform build, preflight signs a challenge with the configured Tauri updater private key/password and verifies it with the configured public key; an invalid or mismatched keypair blocks the release. macOS Universal is Developer ID signed, notarized, and stapled. Windows x64 and ARM64 installer/updater executables are Authenticode signed and timestamped. Tauri updater artifacts cover the default `darwin-aarch64`, `darwin-x86_64`, `windows-x86_64`, and `windows-aarch64` runtime keys; `latest.json` is generated from their non-empty `.sig` files. Release publication occurs only after code-signature/staple verification succeeds. + +Required external gates: + +- Apple: `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`, `APPLE_KEYCHAIN_PASSWORD`. +- Windows: `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD`. +- Updater: `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, and matching public environment variable `TAURI_UPDATER_PUBLIC_KEY`; custody and rotation must be defined before the first stable release. +- Hosted profile build variables: `VITE_AUTHENTIK_ISSUER`, `VITE_AUTHENTIK_CLIENT_ID`, `VITE_ROOTLINE_SYNC_API`. +- Protected `desktop-production` approval and a reviewed `v2.0.0` git tag. + +Missing values produce an actionable preflight error. Do not replace a missing production identity with ad-hoc signing, an empty updater key, `--skip-stapling`, or an unsigned artifact. + +## Release verification and rollback + +After an authorized workflow completes, verify npm provenance and a clean install; image digest, migration log, and HTTPS health; Apple signature/notarization/staple; Windows Authenticode status/timestamp; updater URLs, checksums, and signatures; and that portal wording reflects actual availability. If any platform fails, keep the release blocked rather than publishing a partial stable claim. + +Published versions and migrations are immutable. Fix forward with a new version and migration. Revoke compromised installers/updater keys through the release provider and rotate keys; never overwrite `2.0.0` with different bytes. + +## Related + +- [Operations](operations.md) - API rollout and incidents. +- [Security policy](../SECURITY.md) - Stable supply-chain controls. +- [Privacy](privacy.md) - Data boundaries that releases must preserve. diff --git a/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md b/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md new file mode 100644 index 0000000..7bc81d7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md @@ -0,0 +1,60 @@ +# Rootline Desktop + CLI v2 Implementation Plan + +> **For agentic workers:** Use subagent-driven development and test-driven development. Every production behavior starts with a focused failing test, and every task ends with its own verification and commit. + +**Goal:** Turn `folder-structure-sync` into Rootline by baole.space: a safe visual desktop directory-structure synchronizer for macOS and Windows, backed by the same core as the backwards-compatible CLI and an optional hosted profile-sync API. + +**Architecture:** A pnpm workspace contains an environment-independent TypeScript core, the published Node CLI, shared cloud contracts, a React/Tauri 2 desktop app, and a NestJS/Prisma API. Filesystem data and run history remain local; only explicitly saved profiles are synchronized. + +**Tech stack:** Node.js 20, pnpm, TypeScript, Vitest, Commander, React/Vite, Tauri 2/Rust, SQLite, oidc-client-ts, NestJS 11, Prisma/PostgreSQL. + +## Global Constraints + +- Product display name is `Rootline by baole.space`; Tauri identifier is `space.baole.rootline`. +- Keep npm package `folder-structure-sync` and binary `folder-sync`; it is the only public npm package. Keep private workspace packages version-aligned at `2.0.0` without publishing them. +- Sync is one-way source to target and additive only: create missing directories, never copy/delete files or directories. +- Source and target may not be equal, ancestors, or descendants. Skip symlinks and Windows junctions. +- Node minimum is 20. CLI keeps `--dry-run`, `--verbose`, and `--auto`, and adds `--config` and `--json`. +- Desktop v1 targets macOS Universal and Windows x64/ARM64 only. No Linux, mobile, watcher, scheduler, mirror mode, or CLI cloud profiles. +- Desktop works offline. Optional Authentik login synchronizes complete profiles including absolute paths. +- Cloud conflict policy is server-commit-arrival last-write-wins. Deletes are tombstones. Account-data deletion rotates an epoch so stale devices cannot silently recreate deleted cloud data. +- Absolute paths and tokens never appear in API production logs. Tokens are not stored in localStorage. +- Keep the existing ISC license. No usage telemetry in v1. +- External stable-release gates (Authentik registration, PostgreSQL encrypted deployment, Apple notarization, Windows signing, updater key) must be documented and fail closed when secrets are absent. + +--- + +### Task 1: Recover the Published Baseline and Establish Workspace Contracts + +Recover the exact npm `1.1.0` tarball, verify its registry integrity, document its unreachable original git head, and preserve the `.ignore` directory-pruning behavior without rewriting history. Convert the repository into a pnpm workspace with root orchestration, shared TypeScript/Vitest configuration, `packages/contracts`, and skeleton packages/apps. Define and test the exported workspace domain/cloud types and shared error codes. Do not implement filesystem algorithms, UI behavior, database persistence, or API endpoints yet. + +Acceptance: frozen pnpm install succeeds; contracts tests, typecheck, and package builds pass; npm `1.1.0` recovery evidence is checked into docs; old root implementation remains available as migration evidence but is no longer the future package entry point. + +### Task 2: Implement the Shared Core and Backwards-Compatible CLI v2 + +Use TDD to implement normalized relative paths, exclusion matching, deterministic snapshots/plans/fingerprints, subtree dependency selection, overlap/traversal validation, and shared error objects. Implement the Node filesystem adapter, explicit config resolution, symlink/junction skipping, target case-comparison policy, revalidation, mkdir result statuses, and cancellation boundaries. Build the CLI with the required legacy/new flags, JSON/no-prompt behavior, version sourced from package metadata, and exact exit codes. + +Acceptance: unit/integration/smoke tests cover `.git` versus `.github`, missing target, overlapping roots, symlinks, unreadable paths, stale plans, partial failures, config precedence, auto mode, JSON output, packaging and exit codes. `pnpm --filter folder-structure-sync test` and pack/install smoke pass. + +### Task 3: Implement Rootline Desktop Offline Workflow + +Build the React/Vite/Tauri 2 desktop shell and Rust native boundary. Native code owns folder dialogs, filesystem scan/apply/revalidation/cancellation, filesystem case semantics, SQLite migrations/repositories, device identity, mutation outbox, sync cursor, and capped run history. Build the approved profile rail and `Choose -> Scan -> Review -> Apply` workflow with virtualized diff tree, filters, subtree selection, rebind state, results, Vietnamese/English copy, system light/dark, keyboard/accessibility behavior and reduced motion. + +Acceptance: Rust unit/integration tests cover native safety and SQLite migration/repository behavior; React tests cover workflow, error/empty/loading states, keyboard/focus and 50,000-folder virtualization fixture; Tauri development build starts on macOS and production build succeeds for the host target. + +### Task 4: Implement Authentik and Hosted Profile Sync + +Implement the Rootline public-client OIDC flow with Authorization Code + PKCE, system browser, `rootline://auth/callback`, strict state/nonce/callback validation, Stronghold-backed state/token persistence and OS-protected per-install vault key. Implement the NestJS/Prisma/PostgreSQL API with static-JWKS RS256 validation, tenant scoping by `sub`, permission `rootline:profiles:sync`, DTO limits, rate/body limits, `GET /healthz`, `POST /v1/sync`, and `DELETE /v1/account-data`. Implement idempotent mutation receipts, per-user revisions, delta cursors, LWW, tombstones, epoch reset, offline outbox replay and account-data removal UX. + +Acceptance: API tests with real PostgreSQL cover invalid issuer/audience, cross-tenant access, idempotency, arrival-order LWW, cursor deltas, tombstones, offline replay, limits and reset-required behavior; desktop auth/sync tests prove raw tokens are hidden from components, offline usage is unaffected, and sign-out keeps or explicitly removes local synced data. + +### Task 5: Harden CI, Distribution, Documentation, and Ecosystem Integration + +Add CI for lint/typecheck/tests/builds, Rust fmt/clippy/test, npm tarball smoke and Tauri macOS/Windows build matrices. Add fail-closed release workflows for npm, API image/migration health, signed/notarized installers and signed updater manifests. Update README, security/privacy, architecture, configuration/migration and operations documentation. Add a Rootline product/download entry to the sibling `baole.space` portal using its existing catalog pattern without modifying unrelated portal work. + +Acceptance: all local gates pass; release workflow validation passes without publishing; missing signing/auth/deployment secrets block stable jobs with actionable messages; final branch review has no Critical/Important findings. External credentials and live provider/signing operations are reported as explicit blocked gates, not claimed complete. + +## Related + +- [Rootline documentation](../../README.md) - Documentation navigation. +- [npm `folder-structure-sync@1.1.0` recovery](../../baseline/npm-1.1.0-recovery.md) - The published baseline retained for migration. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..3161d1e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,24 @@ +import eslint from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["**/dist/**", "**/target/**", "apps/desktop/src-tauri/gen/**"], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{js,mjs,ts,tsx}"], + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + }, + { + files: ["**/*.{ts,tsx}"], + rules: { + "no-undef": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, +); diff --git a/package.json b/package.json index 3bb8106..e8682b7 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,39 @@ { - "name": "folder-structure-sync", - "version": "1.0.0", - "description": "🚀 Interactive CLI tool for syncing folder structures with smart selection, dependency handling, and beautiful output", - "main": "index.js", - "bin": { - "folder-sync": "index.js" + "name": "rootline-workspace", + "version": "2.0.0", + "private": true, + "description": "Rootline by baole.space workspace orchestration", + "license": "ISC", + "engines": { + "node": ">=20.0.0" }, + "packageManager": "pnpm@10.33.0", "scripts": { - "start": "node index.js", - "test": "echo \"Error: no test specified\" && exit 1", - "demo": "node index.js --help", - "precheck": "node scripts/pre-publish-check.js", - "test-package": "node scripts/test-package.js", - "release:patch": "node scripts/release.js patch", - "release:minor": "node scripts/release.js minor", - "release:major": "node scripts/release.js major", - "publish:check": "npm run precheck && npm run test-package", - "publish:safe": "npm run publish:check && npm publish", - "workflow:stats": "node scripts/workflow.js stats", - "workflow:social": "node scripts/workflow.js social", - "workflow:promotion": "node scripts/workflow.js promotion", - "workflow:commands": "node scripts/workflow.js commands" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/unique01082/folder-structure-sync.git" - }, - "keywords": [ - "folder", - "sync", - "directory", - "structure", - "cli", - "interactive", - "template", - "project-setup", - "development", - "automation", - "filesystem", - "folders", - "organize" - ], - "author": { - "name": "Bao LE", - "email": "bao.lq.it@gmail.com", - "url": "https://github.com/unique01082" + "audit:prod": "pnpm audit --prod --audit-level high", + "build:workspace-deps": "pnpm --filter @rootline/contracts build && pnpm --filter @rootline/core build", + "build": "pnpm -r --if-present run build", + "lint": "eslint \"apps/**/*.{ts,tsx}\" \"packages/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.mjs\" --max-warnings 0", + "test": "pnpm build:workspace-deps && pnpm -r --if-present run test", + "test:api-image": "sh apps/api/scripts/test-docker-image.sh", + "test:plan": "node --test tests/plan-acceptance.test.mjs", + "test:unit": "pnpm build:workspace-deps && pnpm --filter @rootline/contracts test && pnpm --filter @rootline/core test && pnpm --filter folder-structure-sync test:unit && pnpm --filter @rootline/desktop test", + "test:release": "node --test tests/*.test.mjs", + "validate:workflows": "node --test tests/release-workflows.test.mjs", + "typecheck": "pnpm build:workspace-deps && pnpm --filter @rootline/api prisma:generate && pnpm -r --if-present run typecheck" }, - "license": "ISC", - "bugs": { - "url": "https://github.com/unique01082/folder-structure-sync/issues" - }, - "homepage": "https://github.com/unique01082/folder-structure-sync#readme", - "engines": { - "node": ">=12.0.0" + "pnpm": { + "overrides": { + "js-yaml@>=5.0.0 <=5.2.1": "5.2.2" + } }, - "files": [ - "index.js", - "sync-config.json", - "README.md", - "LICENSE", - "CHANGELOG.md" - ], - "dependencies": { - "chalk": "^4.1.2", - "cli-progress": "^3.12.0", - "commander": "^14.0.0", - "inquirer": "^8.2.6" + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.0.0", + "eslint": "^9.39.1", + "globals": "^16.5.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.46.1", + "vitest": "^3.2.4", + "yaml": "^2.8.1" } } diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..94c4e20 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2025, folder-structure-sync contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..9949738 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,14 @@ +# Rootline CLI + +`folder-structure-sync` provides the `folder-sync` command for safe, additive directory-structure synchronization. It creates missing directories only; it does not copy, move, rename, or delete files. + +Rootline `2.0.0` requires Node.js 20 or newer. Until the protected release workflow succeeds, install from a reviewed local tarball rather than assuming `2.0.0` is live on npm. + +```bash +folder-sync ./source ./target --dry-run +folder-sync ./source ./target --auto --json +``` + +Use `--dry-run` to preview. `--json` never prompts and is accepted only with `--dry-run` or `--auto`; use the latter for non-interactive application. Run `folder-sync --help` for the complete command reference. + +Documentation, source, security policy, and release status: diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..b0e3439 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "folder-structure-sync", + "version": "2.0.0", + "description": "Rootline by baole.space command-line interface", + "license": "ISC", + "repository": { + "type": "git", + "url": "git+https://github.com/unique01082/folder-structure-sync.git" + }, + "engines": { + "node": ">=20" + }, + "type": "module", + "bin": { + "folder-sync": "./dist/index.js" + }, + "files": ["dist"], + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/index.js --sourcemap", + "test": "vitest run", + "test:unit": "vitest run --exclude test/package-smoke.test.ts", + "test:pack": "vitest run test/package-smoke.test.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@rootline/contracts": "workspace:*", + "@rootline/core": "workspace:*", + "esbuild": "^0.28.2" + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..e9ea80a --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +import { readFileSync, realpathSync } from "node:fs"; +import { createInterface } from "node:readline/promises"; +import { resolve } from "node:path"; + +import { + ROOTLINE_ERROR_CODES, + RootlineError, + createRootlineError, + createSnapshot, + createSyncPlan, + validateRootRelationship, +} from "@rootline/core"; +import { NodeFileSystemAdapter, resolveConfig, type ApplyResult } from "./node-adapter.js"; + +export const EXIT_CODES = Object.freeze({ SUCCESS: 0, FAILURE: 1, USAGE: 2 }); + +class UsageError extends Error { + constructor(message: string) { + super(message); + this.name = "UsageError"; + } +} + +interface CliOptions { + readonly source?: string | undefined; + readonly target?: string | undefined; + readonly dryRun: boolean; + readonly verbose: boolean; + readonly auto: boolean; + readonly json: boolean; + readonly configPath?: string | undefined; + readonly help: boolean; + readonly version: boolean; +} + +export interface CliOutput { + source?: { readonly entries: number; readonly skippedSymlinks: readonly string[] }; + target?: { readonly status: string; readonly entries?: number }; + plan?: { readonly fingerprint: string; readonly missing: readonly string[] }; + directories?: ApplyResult["directories"]; + cancelled?: boolean; + error?: { readonly code: string; readonly message: string }; +} + +function version(): string { + const packagePath = new URL("../package.json", import.meta.url); + return (JSON.parse(readFileSync(packagePath, "utf8")) as { version: string }).version; +} + +function usage(): string { + return [ + "Usage: folder-sync [options]", + "", + "Options:", + " -d, --dry-run Preview folders without creating them", + " -v, --verbose Include scan details in text output", + " -a, --auto Create every missing folder without prompts", + " --config Read exclusions from this JSON file", + " --json Emit one JSON document; requires --dry-run or --auto", + " --version Print the package version", + ].join("\n"); +} + +function parseArguments(arguments_: readonly string[]): CliOptions { + const positional: string[] = []; + let dryRun = false; + let verbose = false; + let auto = false; + let json = false; + let configPath: string | undefined; + let help = false; + let showVersion = false; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]!; + if (argument === "-d" || argument === "--dry-run") dryRun = true; + else if (argument === "-v" || argument === "--verbose") verbose = true; + else if (argument === "-a" || argument === "--auto") auto = true; + else if (argument === "--json") json = true; + else if (argument === "-h" || argument === "--help") help = true; + else if (argument === "--version") showVersion = true; + else if (argument === "--config") { + const value = arguments_[index + 1]; + if (!value || value.startsWith("-")) throw configArgumentError(); + configPath = value; + index += 1; + } else if (argument.startsWith("-")) { + throw new UsageError(`Unknown option: ${argument}`); + } else { + positional.push(argument); + } + } + if (!help && !showVersion && positional.length !== 2) { + throw new UsageError("Source and target arguments are required."); + } + if (!help && !showVersion && json && !dryRun && !auto) { + throw new UsageError("--json requires --dry-run or --auto."); + } + return { source: positional[0], target: positional[1], dryRun, verbose, auto, json, configPath, help, version: showVersion }; +} + +function configArgumentError(): UsageError { + return new UsageError("--config requires a path."); +} + +async function confirm(message: string): Promise { + if (!process.stdin.isTTY) { + throw createRootlineError({ code: ROOTLINE_ERROR_CODES.CANCELLED, message: "Interactive confirmation requires a terminal." }); + } + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + return (await readline.question(`${message} [Y/n] `)).trim().toLocaleLowerCase() !== "n"; + } finally { + readline.close(); + } +} + +export async function run( + arguments_: readonly string[], + cwd = process.cwd(), + confirmOperation: (message: string) => Promise = confirm, +): Promise<{ output: CliOutput; exitCode: number; text?: string }> { + let options: CliOptions; + try { + options = parseArguments(arguments_); + if (options.help) return { output: {}, exitCode: EXIT_CODES.SUCCESS, text: usage() }; + if (options.version) return { output: {}, exitCode: EXIT_CODES.SUCCESS, text: version() }; + + const adapter = new NodeFileSystemAdapter(); + const config = await resolveConfig({ cwd, explicitPath: options.configPath }); + const { sourcePath, targetPath } = await adapter.validateRootPaths( + resolve(cwd, options.source!), + resolve(cwd, options.target!), + ); + const targetCaseSensitive = config.targetCaseSensitive ?? await adapter.detectCaseSensitivity(targetPath); + validateRootRelationship(sourcePath, targetPath, targetCaseSensitive); + const source = await adapter.scanDirectories(sourcePath, config.exclusions, "source"); + let targetStatus = await adapter.ensureTarget(targetPath, options.dryRun || !options.auto); + if (targetStatus === "would-create" && !options.dryRun && !options.json) { + if (!(await confirmOperation("Create the missing target directory?"))) { + return { output: { cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; + } + targetStatus = await adapter.ensureTarget(targetPath); + } + const target = targetStatus === "would-create" + ? { + snapshot: createSnapshot([], { + rootPath: targetPath, + caseSensitivity: targetCaseSensitive ? "sensitive" : "insensitive", + }), + skippedSymlinks: [], + } + : await adapter.scanDirectories(targetPath, config.exclusions, "target"); + const plan = createSyncPlan(source.snapshot, target.snapshot, targetCaseSensitive); + const output: CliOutput = { + source: { entries: source.snapshot.entries.length, skippedSymlinks: source.skippedSymlinks }, + target: { status: targetStatus, entries: target.snapshot.entries.length }, + plan: { fingerprint: plan.fingerprint, missing: plan.missing }, + }; + if (plan.missing.length === 0 || options.dryRun) { + if (options.dryRun && targetStatus !== "would-create") { + output.directories = plan.missing.map((relativePath) => ({ relativePath, status: "would-create" })); + } + return { output, exitCode: EXIT_CODES.SUCCESS }; + } + if (!options.auto) { + if (options.json) { + return { output: { ...output, cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; + } + if (!(await confirmOperation(`Create ${plan.missing.length} missing folder(s)?`))) { + return { output: { ...output, cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; + } + } + const result = await adapter.applyDirectories(targetPath, plan, { + exclusions: config.exclusions, + sourcePath, + }); + output.directories = result.directories; + if (result.directories.some((directory) => directory.status === "failed")) { + output.error = { code: ROOTLINE_ERROR_CODES.PARTIAL_FAILURE, message: "Some directories could not be created." }; + return { output, exitCode: EXIT_CODES.FAILURE }; + } + return { output, exitCode: EXIT_CODES.SUCCESS }; + } catch (error: unknown) { + const rootlineError = error instanceof RootlineError + ? error + : createRootlineError({ + code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, + message: error instanceof Error ? error.message : "Unexpected failure.", + }); + return { + output: { error: { code: rootlineError.code, message: rootlineError.message } }, + exitCode: error instanceof UsageError ? EXIT_CODES.USAGE : EXIT_CODES.FAILURE, + }; + } +} + +function printResult(result: { output: CliOutput; exitCode: number; text?: string }, json: boolean, verbose: boolean): void { + if (json) { + process.stdout.write(`${JSON.stringify(result.output)}\n`); + return; + } + if (result.text) { + process.stdout.write(`${result.text}\n`); + } else if (result.output.error) { + process.stderr.write(`Error [${result.output.error.code}]: ${result.output.error.message}\n`); + } else { + if (verbose && result.output.source && result.output.target) { + process.stdout.write(`Source entries: ${result.output.source.entries}\nTarget entries: ${result.output.target.entries ?? 0}\n`); + } + process.stdout.write(`${JSON.stringify(result.output, null, 2)}\n`); + } +} + +const invokedAsCommand = process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(new URL(import.meta.url)); +if (invokedAsCommand) { + const json = process.argv.includes("--json"); + const verbose = process.argv.includes("--verbose") || process.argv.includes("-v"); + run(process.argv.slice(2)).then((result) => { + printResult(result, json, verbose); + process.exitCode = result.exitCode; + }); +} diff --git a/packages/cli/src/node-adapter.ts b/packages/cli/src/node-adapter.ts new file mode 100644 index 0000000..274774c --- /dev/null +++ b/packages/cli/src/node-adapter.ts @@ -0,0 +1,425 @@ +import { promises as fs } from "node:fs"; +import { execFile as execFileCallback } from "node:child_process"; +import { basename, dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { + ROOTLINE_ERROR_CODES, + assertPlanFresh, + createRootlineError, + createSnapshot, + compareRelativePaths, + matchesExclusion, + selectPlanSubtree, + throwIfCancelled, + type CancellationSignalLike, + type DirectorySnapshot, + type SyncPlan, +} from "@rootline/core"; + +export const DEFAULT_EXCLUSIONS = [ + ".git", + ".svn", + ".hg", + "node_modules", + ".npm", + ".yarn", + "bower_components", + ".DS_Store", + "Thumbs.db", + ".vscode", + ".idea", + "*.tmp", + "*.temp", + "*.log", + ".cache", + "dist", + "build", + ".next", + ".nuxt", + "coverage", + ".nyc_output", +] as const; + +const execFile = promisify(execFileCallback); + +export interface ResolvedConfig { + readonly path?: string; + readonly exclusions: readonly string[]; + readonly targetCaseSensitive?: boolean; +} + +export interface ResolveConfigOptions { + readonly cwd: string; + readonly explicitPath?: string | undefined; +} + +interface ConfigFile { + readonly defaultExclusions?: unknown; + readonly customExclusions?: unknown; + readonly targetCaseSensitive?: unknown; +} + +function configError(message: string, path: string, cause?: unknown): never { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, + message, + details: { path, ...(cause instanceof Error ? { cause: cause.message } : {}) }, + }); +} + +function requireStringArray(value: unknown, name: string, path: string): readonly string[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim() === "")) { + return configError(`${name} must be an array of non-empty strings.`, path); + } + return value; +} + +/** Resolves only an explicit config or `sync-config.json` in the requested cwd. */ +export async function resolveConfig(options: ResolveConfigOptions): Promise { + const cwdConfig = join(options.cwd, "sync-config.json"); + const candidate = options.explicitPath ? resolve(options.cwd, options.explicitPath) : cwdConfig; + let raw: string; + try { + raw = await fs.readFile(candidate, "utf8"); + } catch (error: unknown) { + if (!options.explicitPath && isMissing(error)) { + return { exclusions: DEFAULT_EXCLUSIONS }; + } + return configError("Unable to read the configuration file.", candidate, error); + } + + let parsed: ConfigFile; + try { + parsed = JSON.parse(raw) as ConfigFile; + } catch (error: unknown) { + return configError("The configuration file is not valid JSON.", candidate, error); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return configError("The configuration file must contain an object.", candidate); + } + if (parsed.targetCaseSensitive !== undefined && typeof parsed.targetCaseSensitive !== "boolean") { + return configError("targetCaseSensitive must be a boolean.", candidate); + } + const defaults = parsed.defaultExclusions === undefined + ? DEFAULT_EXCLUSIONS + : requireStringArray(parsed.defaultExclusions, "defaultExclusions", candidate); + const custom = requireStringArray(parsed.customExclusions, "customExclusions", candidate); + return { + path: candidate, + exclusions: [...defaults, ...custom], + ...(parsed.targetCaseSensitive === undefined ? {} : { targetCaseSensitive: parsed.targetCaseSensitive }), + }; +} + +function isMissing(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} + +export interface DirectoryScan { + readonly snapshot: DirectorySnapshot; + readonly skippedSymlinks: readonly string[]; +} + +export type DirectoryResultStatus = "created" | "already-exists" | "would-create" | "failed"; + +export interface DirectoryResult { + readonly relativePath: string; + readonly status: DirectoryResultStatus; + readonly error?: string; +} + +export interface ApplyResult { + readonly directories: readonly DirectoryResult[]; +} + +export interface ApplyOptions { + readonly exclusions: readonly string[]; + readonly sourcePath: string; + readonly selected?: readonly string[]; + readonly dryRun?: boolean; + readonly signal?: CancellationSignalLike; +} + +export class NodeFileSystemAdapter { + async validateRootPaths(sourcePath: string, targetPath: string): Promise<{ sourcePath: string; targetPath: string }> { + await this.assertNoLinkedAncestor(sourcePath); + await this.assertNoLinkedAncestor(targetPath); + const [canonicalSource, canonicalTarget] = await Promise.all([ + this.canonicalizeExistingPrefix(sourcePath), + this.canonicalizeExistingPrefix(targetPath), + ]); + return { sourcePath: canonicalSource, targetPath: canonicalTarget }; + } + + async scanDirectories( + rootPath: string, + exclusions: readonly string[], + role: "source" | "target" = "source", + signal?: CancellationSignalLike, + ): Promise { + throwIfCancelled(signal); + const absoluteRoot = resolve(rootPath); + await this.assertNoLinkedAncestor(absoluteRoot); + const canonicalRoot = await this.canonicalizeExistingPrefix(absoluteRoot); + let rootStat; + try { + rootStat = await fs.lstat(canonicalRoot); + } catch (error: unknown) { + if (isMissing(error)) { + throw createRootlineError({ + code: role === "source" ? ROOTLINE_ERROR_CODES.SOURCE_NOT_FOUND : ROOTLINE_ERROR_CODES.TARGET_NOT_FOUND, + message: `${role === "source" ? "Source" : "Target"} directory does not exist.`, + details: { path: canonicalRoot }, + }); + } + throw unreadable(canonicalRoot, error); + } + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw unreadable(canonicalRoot); + } + + const entries: string[] = []; + const skippedSymlinks: string[] = []; + const visit = async (currentPath: string, relativePath: string): Promise => { + throwIfCancelled(signal); + if (await exists(join(currentPath, ".ignore"))) { + return; + } + let names: string[]; + try { + names = (await fs.readdir(currentPath)).sort(compareRelativePaths); + } catch (error: unknown) { + throw unreadable(currentPath, error); + } + for (const name of names) { + throwIfCancelled(signal); + const childRelativePath = relativePath ? `${relativePath}/${name}` : name; + if (matchesExclusion(childRelativePath, exclusions)) { + continue; + } + const childPath = join(currentPath, name); + let stat; + try { + stat = await fs.lstat(childPath); + } catch (error: unknown) { + throw unreadable(childPath, error); + } + if (stat.isSymbolicLink()) { + skippedSymlinks.push(childRelativePath); + continue; + } + if (!stat.isDirectory()) { + continue; + } + entries.push(childRelativePath); + await visit(childPath, childRelativePath); + } + }; + + await visit(canonicalRoot, ""); + const caseSensitive = await this.detectCaseSensitivity(canonicalRoot); + return { + snapshot: createSnapshot(entries, { + rootPath: canonicalRoot, + caseSensitivity: caseSensitive ? "sensitive" : "insensitive", + skippedLinks: skippedSymlinks, + }), + skippedSymlinks: Object.freeze(skippedSymlinks), + }; + } + + async detectCaseSensitivity(path: string): Promise { + const ancestor = await this.nearestExistingAncestor(path); + if (process.platform === "darwin") { + try { + const { stdout } = await execFile("diskutil", ["info", ancestor], { encoding: "utf8" }); + const personality = stdout.split("\n").find((line) => line.includes("File System Personality:")); + return personality?.toLocaleLowerCase().includes("case-sensitive") ?? false; + } catch { + return false; + } + } + if (process.platform === "win32") { + try { + const { stdout } = await execFile("fsutil.exe", ["file", "queryCaseSensitiveInfo", ancestor], { encoding: "utf8" }); + return /enabled/i.test(stdout); + } catch { + return false; + } + } + return true; + } + + async ensureTarget(targetPath: string, dryRun = false): Promise<"created" | "already-exists" | "would-create"> { + const absoluteTarget = resolve(targetPath); + await this.assertNoLinkedAncestor(absoluteTarget); + const canonicalTarget = await this.canonicalizeExistingPrefix(absoluteTarget); + try { + const stat = await fs.lstat(canonicalTarget); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw unreadable(canonicalTarget); + } + return "already-exists"; + } catch (error: unknown) { + if (!isMissing(error)) { + throw error; + } + } + if (dryRun) { + return "would-create"; + } + try { + await this.assertNoLinkedAncestor(absoluteTarget); + await fs.mkdir(canonicalTarget, { recursive: true }); + return "created"; + } catch (error: unknown) { + throw unreadable(canonicalTarget, error); + } + } + + async applyDirectories( + targetPath: string, + plan: SyncPlan, + options: ApplyOptions, + ): Promise { + throwIfCancelled(options.signal); + await this.assertNoLinkedAncestor(targetPath); + await this.assertNoLinkedAncestor(options.sourcePath); + const canonicalTarget = await this.canonicalizeExistingPrefix(targetPath); + const canonicalSource = await this.canonicalizeExistingPrefix(options.sourcePath); + const [currentSource, currentTarget] = await Promise.all([ + this.scanDirectories(canonicalSource, options.exclusions, "source", options.signal), + this.scanDirectories(canonicalTarget, options.exclusions, "target", options.signal), + ]); + if ((plan.sourceRoot && plan.sourceRoot !== currentSource.snapshot.rootPath) + || (plan.targetRoot && plan.targetRoot !== currentTarget.snapshot.rootPath)) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.STALE_PLAN, + message: "The selected roots differ from the reviewed plan.", + }); + } + assertPlanFresh(plan, currentSource.snapshot, currentTarget.snapshot); + const selected = options.selected ?? plan.missing; + const directories = selectPlanSubtree(plan, selected); + const result: DirectoryResult[] = []; + for (const relativePath of directories) { + throwIfCancelled(options.signal); + const fullPath = join(canonicalTarget, ...relativePath.split("/")); + if (options.dryRun) { + result.push({ relativePath, status: "would-create" }); + continue; + } + try { + await this.assertNoLinkedAncestor(fullPath); + const existing = await fs.lstat(fullPath).catch((error: unknown) => (isMissing(error) ? undefined : Promise.reject(error))); + if (existing?.isDirectory()) { + result.push({ relativePath, status: "already-exists" }); + continue; + } + if (existing) { + result.push({ relativePath, status: "failed", error: "A non-directory already exists at this path." }); + continue; + } + await fs.mkdir(fullPath); + result.push({ relativePath, status: "created" }); + } catch (error: unknown) { + result.push({ + relativePath, + status: "failed", + error: error instanceof Error ? error.message : "Unable to create directory.", + }); + } + } + return { directories: Object.freeze(result) }; + } + + private async canonicalizeExistingPrefix(path: string): Promise { + let current = resolve(path); + const missing: string[] = []; + while (true) { + try { + await fs.lstat(current); + const canonical = await fs.realpath(current); + return join(canonical, ...missing); + } catch (error: unknown) { + if (!isMissing(error)) { + throw unreadable(current, error); + } + const parent = dirname(current); + if (parent === current) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.INVALID_PATH, + message: "A synchronization root has no existing canonical ancestor.", + details: { path }, + }); + } + missing.unshift(basename(current)); + current = parent; + } + } + } + + private async nearestExistingAncestor(path: string): Promise { + let current = resolve(path); + while (true) { + try { + await fs.lstat(current); + return current; + } catch (error: unknown) { + if (!isMissing(error)) throw unreadable(current, error); + } + const parent = dirname(current); + if (parent === current) throw unreadable(path); + current = parent; + } + } + + private async assertNoLinkedAncestor(path: string): Promise { + let current = resolve(path); + while (true) { + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.INVALID_PATH, + message: "A synchronization root must not traverse a symbolic link or junction.", + details: { path: resolve(path), linkedAncestor: current }, + }); + } + } catch (error: unknown) { + if (!isMissing(error)) { + throw error; + } + } + const parent = dirname(current); + if (parent === current) { + return; + } + current = parent; + } + } +} + +async function exists(path: string): Promise { + try { + await fs.lstat(path); + return true; + } catch (error: unknown) { + if (isMissing(error)) { + return false; + } + throw unreadable(path, error); + } +} + +function unreadable(path: string, cause?: unknown): ReturnType { + return createRootlineError({ + code: ROOTLINE_ERROR_CODES.UNREADABLE_PATH, + message: "A required path cannot be read as a directory.", + details: { path, ...(cause instanceof Error ? { cause: cause.message } : {}) }, + }); +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..76d887d --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,262 @@ +import { lstat, mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { run as runProgram } from "../src/index.js"; + +const directories: string[] = []; +const cliPath = join(process.cwd(), "dist", "index.js"); +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +async function tempDirectory(): Promise { + const directory = await realpath(await mkdtemp(join(tmpdir(), "rootline-command-test-"))); + directories.push(directory); + return directory; +} + +function run(...arguments_: string[]) { + return spawnSync(process.execPath, [cliPath, ...arguments_], { encoding: "utf8" }); +} + +beforeAll(() => { + const dependencies = spawnSync(pnpmCommand, ["build:workspace-deps"], { + cwd: join(process.cwd(), "../.."), + encoding: "utf8", + shell: process.platform === "win32", + }); + if (dependencies.status !== 0) { + throw new Error(dependencies.stderr || dependencies.stdout); + } + const build = spawnSync(pnpmCommand, ["--filter", "folder-structure-sync", "build"], { + cwd: join(process.cwd(), "../.."), + encoding: "utf8", + shell: process.platform === "win32", + }); + if (build.status !== 0) { + throw new Error(build.stderr || build.stdout); + } +}); + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("folder-sync command", () => { + it("prints help with every legacy and v2 flag", () => { + const result = run("--help"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + for (const flag of ["--dry-run", "--verbose", "--auto", "--config ", "--json"]) { + expect(result.stdout).toContain(flag); + } + }); + + it("reads --version from the package metadata", () => { + const result = run("--version"); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("2.0.0"); + }); + + it("previews a missing target in JSON dry-run mode without creating it or emitting terminal decoration", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await mkdir(join(source, "docs", "api"), { recursive: true }); + + const result = run(source, target, "--dry-run", "--json"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).not.toContain(String.fromCharCode(27)); + expect(result.stdout).not.toMatch(/progress|spinner/i); + expect(JSON.parse(result.stdout)).toMatchObject({ + target: { status: "would-create" }, + plan: { missing: ["docs", "docs/api"] }, + }); + await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("creates a missing target in auto JSON mode without prompting", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await mkdir(join(source, "src", "components"), { recursive: true }); + + const result = run(source, target, "--auto", "--json"); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ target: { status: "created" } }); + }); + + it("rejects JSON mode unless it is explicitly dry-run or auto", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await mkdir(join(source, "src"), { recursive: true }); + + const result = run(source, target, "--json"); + + expect(result.status).toBe(2); + expect(JSON.parse(result.stdout)).toMatchObject({ + error: { code: "CONFIG_INVALID" }, + }); + await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("emits JSON validation errors with the filesystem-failure exit code", async () => { + const workspace = await tempDirectory(); + const target = join(workspace, "target"); + await mkdir(target); + + const result = run(join(workspace, "missing"), target, "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "SOURCE_NOT_FOUND" } }); + }); + + it("rejects an invalid explicit config as a filesystem/config failure", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + const config = join(workspace, "invalid.json"); + await Promise.all([mkdir(source), mkdir(target), writeFile(config, "{not json")]); + + const result = run(source, target, "--auto", "--json", "--config", config); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "CONFIG_INVALID" } }); + }); + + it("returns the filesystem-failure exit code for overlapping roots", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + await mkdir(join(source, "nested"), { recursive: true }); + + const result = run(source, join(source, "nested"), "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "PATH_OVERLAP" } }); + }); + + it("returns the filesystem-failure exit code with partial mkdir statuses", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await mkdir(join(source, "blocked", "child"), { recursive: true }); + await mkdir(source, { recursive: true }); + await mkdir(target); + await writeFile(join(target, "blocked"), "not a directory"); + + const result = run(source, target, "--auto", "--json"); + const output = JSON.parse(result.stdout); + + expect(result.status).toBe(1); + expect(output).toMatchObject({ error: { code: "PARTIAL_FAILURE" } }); + expect(output.directories).toEqual(expect.arrayContaining([ + expect.objectContaining({ relativePath: "blocked", status: "failed" }), + ])); + }); + + it("keeps the legacy verbose flag observable in text output", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await Promise.all([mkdir(join(source, "src"), { recursive: true }), mkdir(target)]); + + const result = run(source, target, "--auto", "--verbose"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Source entries: 1"); + }); + + it("treats an interactive user decline as a successful no-op", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await Promise.all([mkdir(join(source, "src"), { recursive: true }), mkdir(target)]); + + const result = await runProgram([source, target], workspace, async () => false); + + expect(result.exitCode).toBe(0); + expect(result.output).toMatchObject({ cancelled: true }); + }); + + it("uses the usage exit code only for argument parsing errors", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await Promise.all([mkdir(source), mkdir(target)]); + + const unknownFlag = run(source, target, "--not-a-flag", "--json"); + const missingConfigValue = run(source, target, "--config", "--json"); + + expect(unknownFlag.status).toBe(2); + expect(JSON.parse(unknownFlag.stdout)).toMatchObject({ error: { code: "CONFIG_INVALID" } }); + expect(missingConfigValue.status).toBe(2); + expect(JSON.parse(missingConfigValue.stdout)).toMatchObject({ error: { code: "CONFIG_INVALID" } }); + }); + + it("rejects a missing target whose existing ancestor is a source alias before mkdir", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const alias = join(workspace, "alias"); + const target = join(alias, "nested"); + await mkdir(join(source, "planned"), { recursive: true }); + await symlink(source, alias, "dir"); + + const result = run(source, target, "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "INVALID_PATH" } }); + await expect(lstat(join(source, "nested"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects a supplied target symlink before it can create directories outside target", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const external = join(workspace, "external"); + const targetAlias = join(workspace, "target-alias"); + await Promise.all([mkdir(join(source, "required"), { recursive: true }), mkdir(external)]); + await symlink(external, targetAlias, "dir"); + + const result = run(source, targetAlias, "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "INVALID_PATH" } }); + await expect(lstat(join(external, "required"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects a non-overlapping target ancestor link before mkdir", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const external = join(workspace, "external"); + const targetAlias = join(workspace, "target-alias"); + await Promise.all([mkdir(join(source, "required"), { recursive: true }), mkdir(external)]); + await symlink(external, targetAlias, "dir"); + + const result = run(source, join(targetAlias, "nested"), "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "INVALID_PATH" } }); + await expect(lstat(join(external, "nested"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects a supplied source symlink before scanning it", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const sourceAlias = join(workspace, "source-alias"); + const target = join(workspace, "target"); + await Promise.all([mkdir(join(source, "required"), { recursive: true }), mkdir(target)]); + await symlink(source, sourceAlias, "dir"); + + const result = run(sourceAlias, target, "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "INVALID_PATH" } }); + await expect(lstat(join(target, "required"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/cli/test/node-adapter.test.ts b/packages/cli/test/node-adapter.test.ts new file mode 100644 index 0000000..a728cbd --- /dev/null +++ b/packages/cli/test/node-adapter.test.ts @@ -0,0 +1,186 @@ +import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createSnapshot, createSyncPlan } from "@rootline/core"; +import { NodeFileSystemAdapter, resolveConfig } from "../src/node-adapter.js"; + +const temporaryDirectories: string[] = []; + +async function tempDirectory(): Promise { + const directory = await realpath(await mkdtemp(join(tmpdir(), "rootline-cli-test-"))); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("Node filesystem adapter", () => { + it("resolves an explicit config ahead of the working-directory config", async () => { + const cwd = await tempDirectory(); + const explicit = join(cwd, "explicit.json"); + await writeFile(join(cwd, "sync-config.json"), JSON.stringify({ customExclusions: ["cwd-only"] })); + await writeFile(explicit, JSON.stringify({ customExclusions: ["explicit-only"] })); + + await expect(resolveConfig({ cwd, explicitPath: explicit })).resolves.toMatchObject({ + path: explicit, + exclusions: expect.arrayContaining(["explicit-only"]), + }); + await expect(resolveConfig({ cwd })).resolves.toMatchObject({ + path: join(cwd, "sync-config.json"), + exclusions: expect.arrayContaining(["cwd-only"]), + }); + + await writeFile(explicit, JSON.stringify({ defaultExclusions: [], customExclusions: [] })); + await expect(resolveConfig({ cwd, explicitPath: explicit })).resolves.toMatchObject({ exclusions: [] }); + }); + + it("skips symlinks and exact excluded segments while retaining .github", async () => { + const root = await tempDirectory(); + await mkdir(join(root, ".git", "objects"), { recursive: true }); + await mkdir(join(root, ".github", "workflows"), { recursive: true }); + await mkdir(join(root, "real"), { recursive: true }); + await symlink(join(root, "real"), join(root, "linked")); + + const scan = await new NodeFileSystemAdapter().scanDirectories(root, [".git"]); + + expect(scan.snapshot.entries).toEqual([".github", ".github/workflows", "real"]); + expect(scan.skippedSymlinks).toEqual(["linked"]); + }); + + it.runIf(process.platform === "win32")("skips Windows junctions and rejects a junction root", async () => { + const root = await tempDirectory(); + const target = await tempDirectory(); + const outside = await tempDirectory(); + const junction = join(root, "junction"); + await mkdir(join(outside, "secret")); + await symlink(outside, junction, "junction"); + const adapter = new NodeFileSystemAdapter(); + + const scan = await adapter.scanDirectories(root, []); + + expect(scan.snapshot.entries).toEqual([]); + expect(scan.skippedSymlinks).toEqual(["junction"]); + await expect(adapter.validateRootPaths(junction, target)).rejects.toMatchObject({ code: "INVALID_PATH" }); + }); + + it("honors legacy .ignore pruning and maps non-directory roots to unreadable errors", async () => { + const root = await tempDirectory(); + const file = join(root, "file"); + await mkdir(join(root, "ignored", "child"), { recursive: true }); + await writeFile(join(root, "ignored", ".ignore"), ""); + await writeFile(file, "not a directory"); + + await expect(new NodeFileSystemAdapter().scanDirectories(root, [])).resolves.toMatchObject({ + snapshot: { entries: ["ignored"] }, + }); + await expect(new NodeFileSystemAdapter().scanDirectories(file, [])).rejects.toMatchObject({ + code: "UNREADABLE_PATH", + }); + }); + + it.runIf(process.platform !== "win32")("reports an unreadable child directory instead of silently creating an incomplete snapshot", async () => { + const root = await tempDirectory(); + const locked = join(root, "locked"); + await mkdir(locked); + await chmod(locked, 0o000); + + try { + await expect(new NodeFileSystemAdapter().scanDirectories(root, [])).rejects.toMatchObject({ + code: "UNREADABLE_PATH", + }); + } finally { + await chmod(locked, 0o700); + } + }); + + it("reports target mkdir states and observes cancellation at operation boundaries", async () => { + const target = join(await tempDirectory(), "target"); + const adapter = new NodeFileSystemAdapter(); + + await expect(adapter.ensureTarget(target, true)).resolves.toBe("would-create"); + await expect(adapter.ensureTarget(target)).resolves.toBe("created"); + await expect(adapter.ensureTarget(target)).resolves.toBe("already-exists"); + await expect(adapter.scanDirectories(target, [], "source", { aborted: true })).rejects.toMatchObject({ + code: "CANCELLED", + }); + }); + + it("detects the target filesystem case policy without mutating the scanned directory", async () => { + const root = await tempDirectory(); + const sentinel = join(root, "CasePolicySentinel"); + await writeFile(sentinel, "unchanged"); + const before = await readFile(sentinel, "utf8"); + const adapter = new NodeFileSystemAdapter(); + + const detected = await adapter.detectCaseSensitivity(root); + const actual = !(await lstat(join(root, "casepolicysentinel")).then(() => true, () => false)); + + expect(detected).toBe(actual); + expect(await readFile(sentinel, "utf8")).toBe(before); + }); + + it("revalidates stale plans before mkdir and reports per-directory results", async () => { + const target = await tempDirectory(); + const sourceRoot = await tempDirectory(); + const adapter = new NodeFileSystemAdapter(); + const source = createSnapshot(["blocked", "blocked/child", "created"]); + const originalTarget = createSnapshot([]); + const plan = createSyncPlan(source, originalTarget); + await Promise.all(source.entries.map((entry) => mkdir(join(sourceRoot, entry), { recursive: true }))); + await mkdir(join(target, "changed-after-plan")); + await writeFile(join(target, "blocked"), "not a directory"); + + await expect( + adapter.applyDirectories(target, plan, { exclusions: [], sourcePath: sourceRoot }), + ).rejects.toMatchObject({ code: "STALE_PLAN" }); + + const currentPlan = createSyncPlan(source, (await adapter.scanDirectories(target, [])).snapshot); + const result = await adapter.applyDirectories(target, currentPlan, { exclusions: [], sourcePath: sourceRoot }); + + expect(result.directories).toEqual( + expect.arrayContaining([ + expect.objectContaining({ relativePath: "created", status: "created" }), + expect.objectContaining({ relativePath: "blocked/child", status: "failed" }), + ]), + ); + await expect(readFile(join(target, "created"), "utf8")).rejects.toThrow(); + }); + + it("rejects a source tree that changed after planning", async () => { + const sourceRoot = await tempDirectory(); + const target = await tempDirectory(); + const adapter = new NodeFileSystemAdapter(); + await mkdir(join(sourceRoot, "planned")); + const source = (await adapter.scanDirectories(sourceRoot, [])).snapshot; + const plan = createSyncPlan(source, createSnapshot([])); + await mkdir(join(sourceRoot, "added-after-plan")); + + await expect(adapter.applyDirectories(target, plan, { exclusions: [], sourcePath: sourceRoot })).rejects.toMatchObject({ + code: "STALE_PLAN", + }); + }); + + it("does not follow a target child symlink introduced after planning", async () => { + const sourceRoot = await tempDirectory(); + const target = await tempDirectory(); + const external = await tempDirectory(); + const adapter = new NodeFileSystemAdapter(); + await mkdir(join(sourceRoot, "parent", "child"), { recursive: true }); + const source = (await adapter.scanDirectories(sourceRoot, [])).snapshot; + const plan = createSyncPlan(source, createSnapshot([])); + await symlink(external, join(target, "parent"), "dir"); + + const result = await adapter.applyDirectories(target, plan, { exclusions: [], sourcePath: sourceRoot }); + + expect(result.directories).toEqual(expect.arrayContaining([ + expect.objectContaining({ relativePath: "parent", status: "failed" }), + expect.objectContaining({ relativePath: "parent/child", status: "failed" }), + ])); + await expect(lstat(join(external, "child"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/cli/test/package-smoke.test.ts b/packages/cli/test/package-smoke.test.ts new file mode 100644 index 0000000..e99ace5 --- /dev/null +++ b/packages/cli/test/package-smoke.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, mkdir, readFile, realpath, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, it } from "vitest"; + +const workspace = join(process.cwd(), "../.."); +const scratch = await realpath(await mkdtemp(join(tmpdir(), "rootline-package-smoke-"))); +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +function execute(command: string, arguments_: string[], cwd = workspace) { + const result = spawnSync(command, arguments_, { + cwd, + encoding: "utf8", + env: { ...process.env, npm_config_cache: join(scratch, "npm-cache") }, + shell: process.platform === "win32", + }); + if (result.status !== 0) { + throw new Error(`${command} ${arguments_.join(" ")} failed:\n${result.stderr || result.stdout}`); + } + return result; +} + +afterAll(async () => { + await rm(scratch, { recursive: true, force: true }); +}); + +describe("published CLI package", () => { + it("installs from the public CLI tarball alone and runs the folder-sync binary", async () => { + execute(pnpmCommand, ["--filter", "@rootline/contracts", "build"]); + execute(pnpmCommand, ["--filter", "@rootline/core", "build"]); + execute(pnpmCommand, ["--filter", "folder-structure-sync", "build"]); + execute(pnpmCommand, ["--filter", "folder-structure-sync", "pack", "--pack-destination", scratch]); + + const install = join(scratch, "install"); + const source = join(scratch, "source"); + const target = join(scratch, "target"); + await Promise.all([mkdir(install), mkdir(join(source, "nested"), { recursive: true })]); + const tarball = join(scratch, "folder-structure-sync-2.0.0.tgz"); + const listing = execute("tar", ["-tzf", tarball]).stdout.split(/\r?\n/); + expect(listing).toEqual(expect.arrayContaining(["package/LICENSE", "package/README.md"])); + execute("tar", ["-xzf", tarball, "-C", scratch, "package/package.json"]); + const packedManifest = JSON.parse(await readFile(join(scratch, "package", "package.json"), "utf8")); + expect(packedManifest).toMatchObject({ + engines: { node: ">=20" }, + repository: { + type: "git", + url: "git+https://github.com/unique01082/folder-structure-sync.git", + }, + }); + execute("npm", ["install", "--ignore-scripts", tarball], install); + + const result = execute(join(install, "node_modules", ".bin", "folder-sync"), [source, target, "--auto", "--json"], install); + expect(JSON.parse(result.stdout)).toMatchObject({ target: { status: "created" } }); + }, 30_000); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..bb07ae1 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..4a998f3 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, + resolve: { + alias: { + "@rootline/contracts": new URL("../contracts/src/index.ts", import.meta.url).pathname, + "@rootline/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, +}); diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..180409c --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,21 @@ +{ + "name": "@rootline/contracts", + "version": "2.0.0", + "private": true, + "description": "Internal exported domain and cloud contracts for Rootline by baole.space", + "license": "ISC", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run && pnpm run test:types", + "test:types": "tsc -p tsconfig.test.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..93eae6c --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,240 @@ +/** The only synchronization direction supported by Rootline v1. */ +import profileLimits from "./profile-limits.json" with { type: "json" }; + +export const PROFILE_LIMITS = profileLimits; + +export interface ProfileValidationIssue { + field: string; + min?: number; + max: number; +} + +/** Counts Unicode scalar/code-point values, not UTF-16 units or grapheme clusters. */ +export function codePointLength(value: string): number { + return Array.from(value).length; +} + +/** Runtime validation shared by profile editors and transport boundaries. */ +export function validateSyncProfile( + profile: Pick, +): ProfileValidationIssue[] { + const issues: ProfileValidationIssue[] = []; + const lengths = [ + ["name", profile.name, PROFILE_LIMITS.name], + ["sourcePath", profile.sourcePath, PROFILE_LIMITS.path], + ["targetPath", profile.targetPath, PROFILE_LIMITS.path], + ] as const; + for (const [field, value, limits] of lengths) { + const length = codePointLength(value); + if (length < limits.min || length > limits.max) { + issues.push({ field, min: limits.min, max: limits.max }); + } + } + if (profile.exclusions.length > PROFILE_LIMITS.exclusions.max) { + issues.push({ field: "exclusions", max: PROFILE_LIMITS.exclusions.max }); + } + profile.exclusions.forEach((pattern, index) => { + const length = codePointLength(pattern); + const limits = PROFILE_LIMITS.exclusions.pattern; + if (length < limits.min || length > limits.max) { + issues.push({ field: `exclusions[${index}]`, min: limits.min, max: limits.max }); + } + }); + return issues; +} + +export const SYNC_MODES = ["additive"] as const; + +export type SyncMode = (typeof SYNC_MODES)[number]; + +export type CaseSensitivity = "sensitive" | "insensitive"; + +export interface DirectorySnapshot { + rootPath: string; + caseSensitivity: CaseSensitivity; + directories: readonly string[]; + skippedLinks: readonly string[]; +} + +export interface PlanOperation { + id: string; + type: "create-directory"; + relativePath: string; +} + +export interface SyncPlan { + sourceRoot: string; + targetRoot: string; + operations: readonly PlanOperation[]; + fingerprint: string; +} + +/** A complete local profile, including the absolute paths intentionally synced by cloud. */ +export interface SyncProfile { + id: string; + name: string; + sourcePath: string; + targetPath: string; + exclusions: readonly string[]; + createdAt: string; + updatedAt: string; + syncMode?: SyncMode; +} + +export const CLOUD_MUTATION_KINDS = ["upsert", "delete"] as const; + +export type CloudMutationKind = (typeof CLOUD_MUTATION_KINDS)[number]; + +export interface ProfileUpsertMutation { + mutationId: string; + kind: "upsert"; + profile: SyncProfile; + occurredAt: string; +} + +export interface ProfileDeleteMutation { + mutationId: string; + kind: "delete"; + profileId: string; + occurredAt: string; +} + +export type ProfileMutation = ProfileUpsertMutation | ProfileDeleteMutation; + +/** Client-to-server profile changes. The server assigns ordering by arrival. */ +export interface CloudSyncRequest { + deviceId: string; + epoch: string; + cursor?: string; + mutations: readonly ProfileMutation[]; +} + +export const PROFILE_RECORD_KINDS = ["profile", "tombstone"] as const; + +export type ProfileRecordKind = (typeof PROFILE_RECORD_KINDS)[number]; + +export interface SyncedProfileRecord { + kind: "profile"; + profile: SyncProfile; + revision: number; +} + +export interface ProfileTombstoneRecord { + kind: "tombstone"; + profileId: string; + deletedAt: string; + revision: number; +} + +/** A server projection is either a complete profile or a delete tombstone. */ +export type ProfileRecord = SyncedProfileRecord | ProfileTombstoneRecord; + +export interface CloudMutationReceipt { + mutationId: string; + revision: number; +} + +export interface CloudSyncResponse { + epoch: string; + cursor: string; + hasMore: boolean; + records: readonly ProfileRecord[]; + receipts: readonly CloudMutationReceipt[]; +} + +/** Stable v1 wire contract retained for ecosystem clients and documentation. */ +export interface SyncProfileV1 { + id: string; + schemaVersion: 1; + name: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; + revision: string; + deletedAt: string | null; +} + +export type SyncMutation = + | { mutationId: string; type: "upsert"; profile: SyncProfileV1 } + | { mutationId: string; type: "delete"; profileId: string }; + +export interface SyncRequest { + deviceId: string; + accountEpoch: string | null; + cursor: string; + mutations: SyncMutation[]; +} + +export interface SyncResponse { + accountEpoch: string; + cursor: string; + acknowledgedMutationIds: string[]; + profiles: SyncProfileV1[]; +} + +export interface AccountDataDeletionRequest { + epoch: string; +} + +export interface AccountDataDeletionResponse { + epoch: string; +} + +export const ROOTLINE_ERROR_CODES = { + CANCELLED: "CANCELLED", + CONFIG_INVALID: "CONFIG_INVALID", + INVALID_PATH: "INVALID_PATH", + PATH_OVERLAP: "PATH_OVERLAP", + SOURCE_NOT_FOUND: "SOURCE_NOT_FOUND", + TARGET_NOT_FOUND: "TARGET_NOT_FOUND", + UNREADABLE_PATH: "UNREADABLE_PATH", + SYMLINK_SKIPPED: "SYMLINK_SKIPPED", + STALE_PLAN: "STALE_PLAN", + PARTIAL_FAILURE: "PARTIAL_FAILURE", + AUTH_REQUIRED: "AUTH_REQUIRED", + AUTH_CALLBACK_INVALID: "AUTH_CALLBACK_INVALID", + PROFILE_CONFLICT: "PROFILE_CONFLICT", + SYNC_ACCOUNT_CLAIM_REQUIRED: "SYNC_ACCOUNT_CLAIM_REQUIRED", + SYNC_STATE_CHANGED: "SYNC_STATE_CHANGED", + RATE_LIMITED: "RATE_LIMITED", + VALIDATION_FAILED: "VALIDATION_FAILED", + INTERNAL: "INTERNAL", + INVALID_ROOT: "INVALID_ROOT", + OVERLAPPING_ROOTS: "OVERLAPPING_ROOTS", + CREATE_FAILED: "CREATE_FAILED", + SYNC_OFFLINE: "SYNC_OFFLINE", + SYNC_REJECTED: "SYNC_REJECTED", + RESET_REQUIRED: "RESET_REQUIRED", + SCHEMA_UNSUPPORTED: "SCHEMA_UNSUPPORTED", +} as const; + +export type RootlineErrorCode = + (typeof ROOTLINE_ERROR_CODES)[keyof typeof ROOTLINE_ERROR_CODES]; + +export interface RootlineErrorInput { + code: RootlineErrorCode; + message: string; + retryable?: boolean; + details?: Record; +} + +/** A serializable error shape shared by CLI, desktop, and API boundaries. */ +export class RootlineError extends Error { + readonly code: RootlineErrorCode; + readonly retryable: boolean; + readonly details?: Record; + + constructor({ code, message, retryable = false, details }: RootlineErrorInput) { + super(message); + this.name = "RootlineError"; + this.code = code; + this.retryable = retryable; + if (details !== undefined) { + this.details = details; + } + } +} + +export function createRootlineError(input: RootlineErrorInput): RootlineError { + return new RootlineError(input); +} diff --git a/packages/contracts/src/profile-limits.json b/packages/contracts/src/profile-limits.json new file mode 100644 index 0000000..d802fbc --- /dev/null +++ b/packages/contracts/src/profile-limits.json @@ -0,0 +1,6 @@ +{ + "lengthUnit": "unicode-code-points", + "name": { "min": 1, "max": 80 }, + "path": { "min": 1, "max": 4096 }, + "exclusions": { "max": 100, "pattern": { "min": 1, "max": 256 } } +} diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts new file mode 100644 index 0000000..5dd6bd7 --- /dev/null +++ b/packages/contracts/test/contracts.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import * as contracts from "../src/index.js"; + +describe("Rootline contracts", () => { + it("exposes stable shared error codes and structured errors", () => { + expect(contracts.ROOTLINE_ERROR_CODES).toMatchObject({ + CANCELLED: "CANCELLED", + CONFIG_INVALID: "CONFIG_INVALID", + INVALID_PATH: "INVALID_PATH", + PATH_OVERLAP: "PATH_OVERLAP", + SOURCE_NOT_FOUND: "SOURCE_NOT_FOUND", + TARGET_NOT_FOUND: "TARGET_NOT_FOUND", + UNREADABLE_PATH: "UNREADABLE_PATH", + SYMLINK_SKIPPED: "SYMLINK_SKIPPED", + STALE_PLAN: "STALE_PLAN", + PARTIAL_FAILURE: "PARTIAL_FAILURE", + AUTH_REQUIRED: "AUTH_REQUIRED", + AUTH_CALLBACK_INVALID: "AUTH_CALLBACK_INVALID", + PROFILE_CONFLICT: "PROFILE_CONFLICT", + SYNC_ACCOUNT_CLAIM_REQUIRED: "SYNC_ACCOUNT_CLAIM_REQUIRED", + SYNC_STATE_CHANGED: "SYNC_STATE_CHANGED", + RATE_LIMITED: "RATE_LIMITED", + VALIDATION_FAILED: "VALIDATION_FAILED", + INTERNAL: "INTERNAL", + INVALID_ROOT: "INVALID_ROOT", + OVERLAPPING_ROOTS: "OVERLAPPING_ROOTS", + CREATE_FAILED: "CREATE_FAILED", + SYNC_OFFLINE: "SYNC_OFFLINE", + SYNC_REJECTED: "SYNC_REJECTED", + RESET_REQUIRED: "RESET_REQUIRED", + SCHEMA_UNSUPPORTED: "SCHEMA_UNSUPPORTED", + }); + expect(contracts.ROOTLINE_ERROR_CODES).not.toHaveProperty("SYNC_EPOCH_RESET_REQUIRED"); + + const error = contracts.createRootlineError({ + code: contracts.ROOTLINE_ERROR_CODES.PATH_OVERLAP, + message: "Source and target overlap.", + details: { sourcePath: "/source", targetPath: "/source/target" }, + }); + + expect(error).toBeInstanceOf(contracts.RootlineError); + expect(error).toMatchObject({ + code: "PATH_OVERLAP", + message: "Source and target overlap.", + retryable: false, + details: { sourcePath: "/source", targetPath: "/source/target" }, + }); + }); + + it("uses explicit discriminants for additive profiles and cloud mutations", () => { + expect(contracts.SYNC_MODES).toEqual(["additive"]); + expect(contracts.CLOUD_MUTATION_KINDS).toEqual(["upsert", "delete"]); + expect(contracts.PROFILE_RECORD_KINDS).toEqual(["profile", "tombstone"]); + }); + + it("exports the exact shared profile limits and runtime validation", () => { + expect(contracts.PROFILE_LIMITS).toEqual({ + lengthUnit: "unicode-code-points", + name: { min: 1, max: 80 }, + path: { min: 1, max: 4096 }, + exclusions: { max: 100, pattern: { min: 1, max: 256 } }, + }); + expect(contracts.codePointLength("✈️")).toBe(2); + const exactCodePoints = (count: number) => "✈️".repeat(Math.floor(count / 2)) + (count % 2 ? "x" : ""); + const valid = { + id: "profile", + name: exactCodePoints(80), + sourcePath: exactCodePoints(4096), + targetPath: exactCodePoints(4096), + exclusions: Array.from({ length: 100 }, () => exactCodePoints(256)), + createdAt: "2026-08-15T00:00:00Z", + updatedAt: "2026-08-15T00:00:00Z", + }; + expect(contracts.validateSyncProfile(valid)).toEqual([]); + expect(contracts.validateSyncProfile({ + ...valid, + name: exactCodePoints(81), + sourcePath: "", + targetPath: exactCodePoints(4097), + exclusions: [...valid.exclusions, "", exactCodePoints(257)], + })).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: "name" }), + expect.objectContaining({ field: "sourcePath" }), + expect.objectContaining({ field: "targetPath" }), + expect.objectContaining({ field: "exclusions" }), + expect.objectContaining({ field: "exclusions[100]" }), + expect.objectContaining({ field: "exclusions[101]" }), + ])); + }); +}); diff --git a/packages/contracts/test/contracts.types.test.ts b/packages/contracts/test/contracts.types.test.ts new file mode 100644 index 0000000..ff73060 --- /dev/null +++ b/packages/contracts/test/contracts.types.test.ts @@ -0,0 +1,112 @@ +import { expectTypeOf, test } from "vitest"; + +import type { + CloudSyncRequest, + CaseSensitivity, + DirectorySnapshot, + PlanOperation, + ProfileRecord, + RootlineErrorCode, + SyncMutation, + SyncProfileV1, + SyncRequest, + SyncResponse, + SyncPlan, + SyncProfile, +} from "../src/index.js"; + +test("public domain and cloud contracts retain their transport shapes", () => { + expectTypeOf().toEqualTypeOf<"sensitive" | "insensitive">(); + expectTypeOf().toEqualTypeOf<{ + rootPath: string; + caseSensitivity: CaseSensitivity; + directories: readonly string[]; + skippedLinks: readonly string[]; + }>(); + expectTypeOf().toEqualTypeOf<{ + id: string; + type: "create-directory"; + relativePath: string; + }>(); + expectTypeOf().toEqualTypeOf<{ + sourceRoot: string; + targetRoot: string; + operations: readonly PlanOperation[]; + fingerprint: string; + }>(); + expectTypeOf().toMatchTypeOf<{ + id: string; + name: string; + sourcePath: string; + targetPath: string; + exclusions: readonly string[]; + createdAt: string; + updatedAt: string; + }>(); + + expectTypeOf().toMatchTypeOf<{ + deviceId: string; + epoch: string; + mutations: readonly unknown[]; + }>(); + + expectTypeOf().toMatchTypeOf< + | { kind: "profile"; profile: SyncProfile; revision: number } + | { kind: "tombstone"; profileId: string; deletedAt: string; revision: number } + >(); + + expectTypeOf().toEqualTypeOf<{ + id: string; + schemaVersion: 1; + name: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; + revision: string; + deletedAt: string | null; + }>(); + expectTypeOf().toEqualTypeOf< + | { mutationId: string; type: "upsert"; profile: SyncProfileV1 } + | { mutationId: string; type: "delete"; profileId: string } + >(); + expectTypeOf().toEqualTypeOf<{ + deviceId: string; + accountEpoch: string | null; + cursor: string; + mutations: SyncMutation[]; + }>(); + expectTypeOf().toEqualTypeOf<{ + accountEpoch: string; + cursor: string; + acknowledgedMutationIds: string[]; + profiles: SyncProfileV1[]; + }>(); + + expectTypeOf().toEqualTypeOf< + | "CANCELLED" + | "CONFIG_INVALID" + | "INVALID_PATH" + | "PATH_OVERLAP" + | "SOURCE_NOT_FOUND" + | "TARGET_NOT_FOUND" + | "UNREADABLE_PATH" + | "SYMLINK_SKIPPED" + | "STALE_PLAN" + | "PARTIAL_FAILURE" + | "AUTH_REQUIRED" + | "AUTH_CALLBACK_INVALID" + | "PROFILE_CONFLICT" + | "SYNC_ACCOUNT_CLAIM_REQUIRED" + | "SYNC_STATE_CHANGED" + | "RATE_LIMITED" + | "VALIDATION_FAILED" + | "INTERNAL" + | "INVALID_ROOT" + | "OVERLAPPING_ROOTS" + | "CREATE_FAILED" + | "SYNC_OFFLINE" + | "SYNC_REJECTED" + | "RESET_REQUIRED" + | "SCHEMA_UNSUPPORTED" + >(); +}); diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..7b6cf53 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/packages/contracts/tsconfig.test.json b/packages/contracts/tsconfig.test.json new file mode 100644 index 0000000..45bbe8f --- /dev/null +++ b/packages/contracts/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/packages/contracts/vitest.config.ts b/packages/contracts/vitest.config.ts new file mode 100644 index 0000000..8b5840a --- /dev/null +++ b/packages/contracts/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..23e2062 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,24 @@ +{ + "name": "@rootline/core", + "version": "2.0.0", + "private": true, + "description": "Rootline environment-independent synchronization core", + "license": "ISC", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run && pnpm run test:types", + "test:types": "tsc -p tsconfig.test.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@rootline/contracts": "workspace:*" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..28367f7 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,248 @@ +import { + ROOTLINE_ERROR_CODES, + RootlineError, + createRootlineError, +} from "@rootline/contracts"; + +export { ROOTLINE_ERROR_CODES, RootlineError, createRootlineError }; + +export type CaseSensitivity = "sensitive" | "insensitive"; + +export interface DirectorySnapshot { + readonly rootPath: string; + readonly caseSensitivity: CaseSensitivity; + readonly directories: readonly string[]; + readonly skippedLinks: readonly string[]; + readonly entries: readonly string[]; + readonly fingerprint: string; +} + +export interface PlanOperation { + readonly id: string; + readonly type: "create-directory"; + readonly relativePath: string; +} + +export interface SyncPlan { + readonly sourceRoot: string; + readonly targetRoot: string; + readonly operations: readonly PlanOperation[]; + readonly sourceFingerprint: string; + readonly targetFingerprint: string; + readonly targetCaseSensitive: boolean; + readonly missing: readonly string[]; + readonly fingerprint: string; +} + +export interface CancellationSignalLike { + readonly aborted: boolean; +} + +function invalidPath(message: string, value: string): never { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.INVALID_PATH, + message, + details: { path: value }, + }); +} + +/** Converts a relative directory name to the portable format used by snapshots. */ +export function normalizeRelativePath(value: string): string { + const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/"); + if (normalized === "" || normalized === ".") { + return ""; + } + if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) { + return invalidPath("A relative path must not be absolute.", value); + } + + const parts = normalized.split("/"); + if (parts.some((part) => part === "" || part === "." || part === "..")) { + return invalidPath("A relative path must not traverse outside its root.", value); + } + return parts.join("/"); +} + +function globToRegExp(pattern: string): RegExp { + let expression = ""; + for (let index = 0; index < pattern.length; index += 1) { + const token = pattern[index]!; + if (token === "*" && pattern[index + 1] === "*") { + index += 1; + if (pattern[index + 1] === "/") { + index += 1; + expression += "(?:.*/)?"; + } else { + expression += ".*"; + } + } else if (token === "*") { + expression += "[^/]*"; + } else if (token === "?") { + expression += "[^/]"; + } else { + expression += token.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + } + } + return new RegExp(`^${expression}$`); +} + +/** Matches complete path segments, so `.git` never also excludes `.github`. */ +export function matchesExclusion(relativePath: string, patterns: readonly string[]): boolean { + const path = normalizeRelativePath(relativePath); + if (!path) { + return false; + } + const segments = path.split("/"); + return patterns.some((rawPattern) => { + const pattern = normalizeRelativePath(rawPattern); + if (!pattern) { + return false; + } + if (pattern.includes("/")) { + return globToRegExp(pattern).test(path); + } + const matcher = globToRegExp(pattern); + return segments.some((segment) => matcher.test(segment)); + }); +} + +/** Stable, dependency-free fingerprint for an already sorted sequence of paths. */ +export function fingerprint(entries: readonly string[]): string { + let hash = 0x811c9dc5; + for (const entry of entries) { + for (let index = 0; index < entry.length; index += 1) { + hash ^= entry.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + hash ^= 10; + hash = Math.imul(hash, 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +/** Locale-independent ordering keeps plans and fingerprints portable across hosts. */ +export function compareRelativePaths(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export interface SnapshotMetadata { + readonly rootPath?: string; + readonly caseSensitivity?: CaseSensitivity; + readonly skippedLinks?: readonly string[]; +} + +export function createSnapshot(entries: readonly string[], metadata: SnapshotMetadata = {}): DirectorySnapshot { + const normalized = [...new Set(entries.map(normalizeRelativePath).filter(Boolean))].sort(compareRelativePaths); + const directories = Object.freeze(normalized); + return Object.freeze({ + rootPath: metadata.rootPath ?? "", + caseSensitivity: metadata.caseSensitivity ?? "sensitive", + directories, + skippedLinks: Object.freeze([...(metadata.skippedLinks ?? [])]), + entries: directories, + fingerprint: fingerprint(normalized), + }); +} + +export function createSyncPlan( + source: DirectorySnapshot, + target: DirectorySnapshot, + targetCaseSensitive = target.caseSensitivity === "sensitive", +): SyncPlan { + const comparable = (entry: string) => targetCaseSensitive ? entry : entry.toLowerCase(); + const targetEntries = new Set(target.entries.map(comparable)); + const missing = source.entries.filter((entry) => !targetEntries.has(comparable(entry))); + const operations = missing.map((relativePath) => Object.freeze({ + id: fingerprint([source.rootPath, target.rootPath, relativePath]), + type: "create-directory" as const, + relativePath, + })); + const rootBinding = source.rootPath || target.rootPath ? [source.rootPath, target.rootPath] : []; + const planEntries = [...rootBinding, source.fingerprint, target.fingerprint, targetCaseSensitive ? "case-sensitive" : "case-insensitive", ...missing]; + return Object.freeze({ + sourceRoot: source.rootPath, + targetRoot: target.rootPath, + operations: Object.freeze(operations), + sourceFingerprint: source.fingerprint, + targetFingerprint: target.fingerprint, + targetCaseSensitive, + missing: Object.freeze(missing), + fingerprint: fingerprint(planEntries), + }); +} + +/** Expands a requested subtree with only the parents that are also missing. */ +export function selectPlanSubtree(plan: SyncPlan, requested: readonly string[]): string[] { + const missing = new Set(plan.missing); + const selected = new Set(); + for (const rawPath of requested) { + const path = normalizeRelativePath(rawPath); + if (!missing.has(path)) { + continue; + } + const parts = path.split("/"); + for (let depth = 1; depth <= parts.length; depth += 1) { + const parent = parts.slice(0, depth).join("/"); + if (missing.has(parent)) { + selected.add(parent); + } + } + } + return [...selected].sort((left, right) => { + const depth = left.split("/").length - right.split("/").length; + return depth === 0 ? compareRelativePaths(left, right) : depth; + }); +} + +function normalizeRootPath(value: string, caseSensitive: boolean): string { + const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, ""); + return caseSensitive ? normalized : normalized.toLowerCase(); +} + +/** Rejects equal, ancestor, and descendant roots before any filesystem mutation. */ +export function validateRootRelationship(sourcePath: string, targetPath: string, caseSensitive: boolean): void { + const source = normalizeRootPath(sourcePath, caseSensitive); + const target = normalizeRootPath(targetPath, caseSensitive); + if (!source || !target) { + invalidPath("A synchronization root must not be empty.", !source ? sourcePath : targetPath); + } + if (source === target || source.startsWith(`${target}/`) || target.startsWith(`${source}/`)) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.PATH_OVERLAP, + message: "Source and target roots must not overlap.", + details: { sourcePath, targetPath }, + }); + } +} + +export function assertPlanFresh( + plan: SyncPlan, + source: DirectorySnapshot, + target: DirectorySnapshot, +): void { + const expectedSource = plan.sourceRoot ? source : createSnapshot(source.entries); + const expectedTarget = plan.targetRoot ? target : createSnapshot(target.entries); + const expected = createSyncPlan(expectedSource, expectedTarget, plan.targetCaseSensitive); + if ( + plan.sourceFingerprint !== source.fingerprint || + plan.targetFingerprint !== target.fingerprint || + plan.fingerprint !== expected.fingerprint || + plan.missing.length !== expected.missing.length || + plan.missing.some((entry, index) => entry !== expected.missing[index]) + ) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.STALE_PLAN, + message: "The filesystem changed after this plan was created.", + details: { expectedPlan: plan.fingerprint }, + }); + } +} + +export function throwIfCancelled(signal?: CancellationSignalLike): void { + if (signal?.aborted) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.CANCELLED, + message: "The synchronization was cancelled.", + }); + } +} diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts new file mode 100644 index 0000000..8713543 --- /dev/null +++ b/packages/core/test/core.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + assertPlanFresh, + createSnapshot, + createSyncPlan, + matchesExclusion, + normalizeRelativePath, + selectPlanSubtree, + throwIfCancelled, + validateRootRelationship, +} from "../src/index.js"; + +describe("Rootline synchronization core", () => { + it("normalizes portable relative paths and rejects traversal", () => { + expect(normalizeRelativePath("src\\components//ui")).toBe("src/components/ui"); + expect(normalizeRelativePath(" docs ")).toBe(" docs "); + expect(() => normalizeRelativePath("../outside")).toThrow("relative path"); + }); + + it("excludes an exact path segment without excluding similarly named directories", () => { + expect(matchesExclusion(".git/hooks", [".git"])).toBe(true); + expect(matchesExclusion(".github/workflows", [".git"])).toBe(false); + expect(matchesExclusion("build/cache", ["build"])).toBe(true); + expect(matchesExclusion("notes/error.log", ["*.log"])).toBe(true); + expect(matchesExclusion("notes/app1.log", ["app?.log"])).toBe(true); + expect(matchesExclusion("notes/app10.log", ["app?.log"])).toBe(false); + expect(matchesExclusion("generated/deep/cache", ["generated/**/cache"])).toBe(true); + expect(matchesExclusion("generated/cache", ["generated/**/cache"])).toBe(true); + expect(matchesExclusion("generated/deep/nested/cache", ["generated/*/cache"])).toBe(false); + }); + + it("creates deterministic snapshots and dependency-complete subtree selections", () => { + const source = createSnapshot(["app/routes", "app", "docs", "app/routes/admin"]); + const target = createSnapshot(["docs"]); + const plan = createSyncPlan(source, target); + + expect(source.entries).toEqual(["app", "app/routes", "app/routes/admin", "docs"]); + expect(plan.missing).toEqual(["app", "app/routes", "app/routes/admin"]); + expect(selectPlanSubtree(plan, ["app/routes/admin"])).toEqual([ + "app", + "app/routes", + "app/routes/admin", + ]); + expect(createSnapshot([...source.entries].reverse()).fingerprint).toBe(source.fingerprint); + expect(createSnapshot(["z", "ä", "a"]).entries).toEqual(["a", "z", "ä"]); + }); + + it("exports immutable public snapshot and operation-plan fields", () => { + const source = createSnapshot(["docs/api", "docs"], { + rootPath: "/source", + caseSensitivity: "sensitive", + skippedLinks: ["linked"], + }); + const target = createSnapshot(["docs"], { + rootPath: "/target", + caseSensitivity: "insensitive", + }); + const plan = createSyncPlan(source, target, false); + + expect(source).toMatchObject({ + rootPath: "/source", + caseSensitivity: "sensitive", + directories: ["docs", "docs/api"], + skippedLinks: ["linked"], + }); + expect(plan).toMatchObject({ sourceRoot: "/source", targetRoot: "/target" }); + expect(plan.operations).toEqual([ + expect.objectContaining({ type: "create-directory", relativePath: "docs/api" }), + ]); + expect(plan.operations[0]?.id).toBe(createSyncPlan(source, target, false).operations[0]?.id); + expect(createSyncPlan(source, createSnapshot(["docs"], { rootPath: "/other-target" }), false).fingerprint) + .not.toBe(plan.fingerprint); + expect(Object.isFrozen(plan.operations)).toBe(true); + }); + + it("rejects equal or overlapping synchronization roots", () => { + expect(() => validateRootRelationship("/work/source", "/work/source/nested", true)).toThrow( + "overlap", + ); + expect(() => validateRootRelationship("C:\\Source", "c:/source", false)).toThrow("overlap"); + }); + + it("does not depend on the host locale for case-insensitive overlap safety", () => { + const localeLowerCase = String.prototype.toLocaleLowerCase; + String.prototype.toLocaleLowerCase = function localeSensitiveLowerCase(): string { + return this.toString(); + }; + try { + expect(() => validateRootRelationship("/work/I", "/work/i", false)).toThrow("overlap"); + } finally { + String.prototype.toLocaleLowerCase = localeLowerCase; + } + }); + + it("uses the target case policy and rejects stale or cancelled work", () => { + const source = createSnapshot(["Components"]); + const target = createSnapshot(["components"]); + const plan = createSyncPlan(source, createSnapshot([])); + + expect(createSyncPlan(source, target, false).missing).toEqual([]); + expect(() => assertPlanFresh(plan, source, target)).toThrow("changed"); + expect(() => throwIfCancelled({ aborted: true })).toThrow("cancelled"); + + const forgedPlan = { ...plan, missing: ["outside"], fingerprint: plan.fingerprint }; + expect(() => assertPlanFresh(forgedPlan, source, createSnapshot([]))).toThrow("changed"); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 0000000..45bbe8f --- /dev/null +++ b/packages/core/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..9b3c242 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, + resolve: { + alias: { + "@rootline/contracts": new URL("../contracts/src/index.ts", import.meta.url).pathname, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5502d29 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4865 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + js-yaml@>=5.0.0 <=5.2.1: 5.2.2 + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.5 + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + eslint: + specifier: ^9.39.1 + version: 9.39.5(jiti@2.7.0) + globals: + specifier: ^16.5.0 + version: 16.5.0 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.46.1 + version: 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12)(yaml@2.9.0) + yaml: + specifier: ^2.8.1 + version: 2.9.0 + + apps/api: + dependencies: + '@nestjs/common': + specifier: ^11.1.6 + version: 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.2 + version: 4.0.4(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.1.6 + version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-express': + specifier: ^11.1.6 + version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1) + '@nestjs/swagger': + specifier: ^11.2.0 + version: 11.4.6(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@prisma/client': + specifier: ^6.16.2 + version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + '@rootline/contracts': + specifier: workspace:* + version: link:../../packages/contracts + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.2 + version: 0.14.4 + express: + specifier: ^5.1.0 + version: 5.2.1 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + devDependencies: + '@nestjs/testing': + specifier: ^11.1.6 + version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(@nestjs/platform-express@11.2.1) + '@types/express': + specifier: ^5.0.3 + version: 5.0.6 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + jose: + specifier: ^6.1.0 + version: 6.2.8 + prisma: + specifier: ^6.16.2 + version: 6.19.3(typescript@5.9.3) + supertest: + specifier: ^7.1.4 + version: 7.2.2 + tsx: + specifier: ^4.20.5 + version: 4.23.12 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12)(yaml@2.9.0) + + apps/desktop: + dependencies: + '@rootline/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@tauri-apps/api': + specifier: ^2.8.0 + version: 2.11.1 + '@tauri-apps/plugin-deep-link': + specifier: ^2.4.3 + version: 2.4.9 + '@tauri-apps/plugin-opener': + specifier: ^2.5.0 + version: 2.5.4 + '@tauri-apps/plugin-stronghold': + specifier: ^2.3.0 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.0 + version: 2.10.1 + oidc-client-ts: + specifier: ^3.3.0 + version: 3.5.0 + react: + specifier: ^19.1.1 + version: 19.2.8 + react-dom: + specifier: ^19.1.1 + version: 19.2.8(react@19.2.8) + devDependencies: + '@tauri-apps/cli': + specifier: ^2.8.4 + version: 2.11.4 + '@testing-library/jest-dom': + specifier: ^6.8.0 + version: 6.10.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.4(@testing-library/dom@10.4.1) + '@types/react': + specifier: ^19.1.10 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.7 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^5.0.2 + version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + axe-core: + specifier: ^4.10.3 + version: 4.13.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + vite: + specifier: ^7.1.2 + version: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12)(yaml@2.9.0) + + packages/cli: + devDependencies: + '@rootline/contracts': + specifier: workspace:* + version: link:../contracts + '@rootline/core': + specifier: workspace:* + version: link:../core + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + + packages/contracts: {} + + packages/core: + dependencies: + '@rootline/contracts': + specifier: workspace:* + version: link:../contracts + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@nestjs/common@11.2.1': + resolution: {integrity: sha512-SEgtP+M9DqNhQkgJIlJ3oTp3gemo/8owySovzMGmJj2kcfIH1G6QP45AAb8dE4a3IpVicpUvdDAy7Syk7ebjBw==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.2.1': + resolution: {integrity: sha512-M5PWFU8NdRTgX9Po49d7TQKg7f5t8GAVUa/Esy4tmaMWcKugTzg3ZzpJfD3LEPMuRHjfs6+8pQYgtuP2uz3rDw==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + + '@nestjs/platform-express@11.2.1': + resolution: {integrity: sha512-lbaVW94s1u8AJfgmBtdMPi16MEuFBiLrnflUIA9tZ9e5eoUsGTN7XXXRjU5kTTLgjnTCM53qgBXXGmTqmyfoQA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/swagger@11.4.6': + resolution: {integrity: sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/testing@11.2.1': + resolution: {integrity: sha512-3mdABjqFafW+ix6fGJMPHvnj/9Or6kAPiszN8zt9bHGPuHYdkkp+9zsBDDUVuP59BcbzBqNBa5fXHBaeMzVvog==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@prisma/client@6.19.3': + resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.3': + resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + + '@prisma/debug@6.19.3': + resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.3': + resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + + '@prisma/fetch-engine@6.19.3': + resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + + '@prisma/get-platform@6.19.3': + resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-deep-link@2.4.9': + resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@tauri-apps/plugin-stronghold@2.3.1': + resolution: {integrity: sha512-zFbD1Apk/VFdWaoGaoKcouRrZnzLFiNY9b1KDeBaN47sMaMHRYIa+ZDhvbzMOyH314+OHCQBXfe8I/ph59Lp9g==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.4': + resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.14: + resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} + engines: {node: '>=6.0.0'} + hasBin: true + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.21.0: + resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + + electron-to-chromium@1.5.406: + resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + js-yaml@5.2.2: + resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} + hasBin: true + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libphonenumber-js@1.13.11: + resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + nypm@0.6.9: + resolution: {integrity: sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==} + engines: {node: '>=18'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + oidc-client-ts@3.5.0: + resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} + engines: {node: '>=18'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prisma@6.19.3: + resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + swagger-ui-dist@5.32.8: + resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@borewit/text-codec@0.2.2': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lukeed/csprng@1.1.0': {} + + '@microsoft/tsdoc@0.16.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.4(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 + dotenv-expand: 12.0.3 + lodash: 4.18.1 + rxjs: 7.8.2 + + '@nestjs/core@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1) + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/passport@11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.2.0 + path-to-regexp: 8.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/swagger@11.4.6(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + js-yaml: 5.2.2 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.8 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/testing@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(@nestjs/platform-express@11.2.1)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1) + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.3(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.3': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.21.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.3': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.3 + '@prisma/get-platform': 6.19.3 + + '@prisma/fetch-engine@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.3 + + '@prisma/get-platform@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + + '@tauri-apps/plugin-deep-link@2.4.9': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-stronghold@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.13.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.13.3 + + '@types/cookiejar@2.1.5': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 24.13.3 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.13.3 + + '@types/methods@1.1.4': {} + + '@types/ms@2.1.0': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.13.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.13.3 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.13.3 + form-data: 4.0.6 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/validator@13.15.10': {} + + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + append-field@1.0.0: {} + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + axe-core@4.13.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.14: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.14 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.406 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 16.6.1 + exsolve: 1.1.1 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.1 + rc9: 2.1.2 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001809: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + class-transformer@0.5.1: {} + + class-validator@0.14.4: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.11 + validator: 13.15.35 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.4.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + effect@3.21.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + electron-to-chromium@1.5.406: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + expect-type@1.4.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.1.1: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.9 + pathe: 2.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + iterare@1.2.1: {} + + jiti@2.7.0: {} + + jose@6.2.8: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + js-yaml@5.2.2: + dependencies: + argparse: 2.0.1 + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + jwt-decode@4.0.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libphonenumber-js@1.13.11: {} + + load-esm@1.0.3: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash@4.18.1: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + ms@2.1.3: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + node-fetch-native@1.6.7: {} + + node-releases@2.0.53: {} + + nwsapi@2.2.24: {} + + nypm@0.6.9: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + ohash@2.0.12: {} + + oidc-client-ts@3.5.0: + dependencies: + jwt-decode: 4.0.0 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pause@0.0.1: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prisma@6.19.3(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.3 + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-refresh@0.18.0: {} + + react@19.2.8: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect-metadata@0.2.2: {} + + resolve-from@4.0.0: {} + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rrweb-cssom@0.8.0: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + streamsearch@1.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + swagger-ui-dist@5.32.8: + dependencies: + '@scarf/scarf': 1.4.0 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + + typescript-eslint@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + undici-types@7.18.2: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + validator@13.15.35: {} + + vary@1.1.2: {} + + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.12 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + ws@8.21.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/scripts/create-updater-manifest.mjs b/scripts/create-updater-manifest.mjs new file mode 100644 index 0000000..3be1689 --- /dev/null +++ b/scripts/create-updater-manifest.mjs @@ -0,0 +1,32 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const [artifactDirectory, releaseBaseUrl, outputPath] = process.argv.slice(2); +if (!artifactDirectory || !releaseBaseUrl || !outputPath) { + throw new Error("Usage: create-updater-manifest "); +} +const baseUrl = new URL(releaseBaseUrl); +if (baseUrl.protocol !== "https:") throw new Error("Updater release base URL must use HTTPS."); + +const artifacts = { + "darwin-aarch64": "rootline-2.0.0-darwin-universal.app.tar.gz", + "darwin-x86_64": "rootline-2.0.0-darwin-universal.app.tar.gz", + "windows-x86_64": "rootline-2.0.0-windows-x86_64-setup.exe", + "windows-aarch64": "rootline-2.0.0-windows-aarch64-setup.exe", +}; +const platforms = Object.fromEntries(Object.entries(artifacts).map(([platform, fileName]) => { + const signature = readFileSync(join(artifactDirectory, `${fileName}.sig`), "utf8").trim(); + if (!signature) throw new Error(`Updater signature is empty for ${platform}.`); + readFileSync(join(artifactDirectory, fileName)); + return [platform, { + signature, + url: new URL(encodeURIComponent(fileName), `${baseUrl.toString().replace(/\/?$/, "/")}`).toString(), + }]; +})); + +writeFileSync(outputPath, `${JSON.stringify({ + version: "2.0.0", + notes: "Rootline by baole.space 2.0.0", + pub_date: process.env.RELEASE_PUBLISHED_AT || new Date().toISOString(), + platforms, +}, null, 2)}\n`); diff --git a/tests/plan-acceptance.test.mjs b/tests/plan-acceptance.test.mjs new file mode 100644 index 0000000..431cc6d --- /dev/null +++ b/tests/plan-acceptance.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = (...parts) => readFileSync(join(root, ...parts), "utf8"); +const json = (...parts) => JSON.parse(read(...parts)); + +test("Task 1 preserves the exact npm 1.1.0 baseline and workspace contract", () => { + const tarballPath = join(root, "docs", "baseline", "folder-structure-sync-1.1.0.tgz"); + const tarball = readFileSync(tarballPath); + assert.equal(createHash("sha1").update(tarball).digest("hex"), "5cbc1470b492f36bad11653f2b8545a62daf5929"); + assert.equal( + createHash("sha512").update(tarball).digest("base64"), + "DMLwBKls8g/9ZSx2iSTW6ChOkuZSFjoe8cnn3k1NO7oM0D0sRNGXy2GuipFqhtukoUGwI1YdfENsHfzCZTjY1w==", + ); + const recoveredManifest = JSON.parse(execFileSync("tar", ["-xOzf", tarballPath, "package/package.json"], { encoding: "utf8" })); + const recoveredSource = execFileSync("tar", ["-xOzf", tarballPath, "package/index.js"], { encoding: "utf8" }); + assert.equal(recoveredManifest.version, "1.1.0"); + assert.match(recoveredSource, /\.ignore/); + + const evidence = read("docs", "baseline", "npm-1.1.0-recovery.md"); + assert.match(evidence, /a9cb35279f65db6e939c884a0503f2e13b3a5d93/); + assert.match(evidence, /not present in this clone/); + assert.match(evidence, /does not amend, reset, or otherwise rewrite repository history/); + + const manifests = [ + json("package.json"), json("apps", "api", "package.json"), json("apps", "desktop", "package.json"), + json("packages", "core", "package.json"), json("packages", "contracts", "package.json"), json("packages", "cli", "package.json"), + ]; + assert.deepEqual(manifests.map((manifest) => manifest.version), Array(6).fill("2.0.0")); + assert.deepEqual(manifests.map((manifest) => manifest.license), Array(6).fill("ISC")); + assert.deepEqual(manifests.filter((manifest) => manifest.private !== true).map((manifest) => manifest.name), ["folder-structure-sync"]); + assert.equal(manifests[0].private, true); + assert.match(manifests[0].engines.node, />=20/); + assert.match(manifests[0].scripts.typecheck, /build:workspace-deps/); + assert.match(manifests[0].scripts.typecheck, /prisma:generate/); + assert.match(manifests[0].scripts.test, /build:workspace-deps/); + assert.match(read("pnpm-workspace.yaml"), /apps\/\*/); + assert.match(read("pnpm-workspace.yaml"), /packages\/\*/); +}); + +test("Tasks 2 and 3 keep the public CLI identity and desktop safety, identity, theme, and scope contracts", () => { + const cli = json("packages", "cli", "package.json"); + assert.equal(cli.name, "folder-structure-sync"); + assert.equal(cli.bin["folder-sync"], "./dist/index.js"); + assert.match(cli.engines.node, />=20/); + + const tauri = json("apps", "desktop", "src-tauri", "tauri.conf.json"); + assert.equal(tauri.productName, "Rootline by baole.space"); + assert.equal(tauri.identifier, "space.baole.rootline"); + assert.equal(tauri.version, "2.0.0"); + assert.deepEqual(tauri.plugins["deep-link"].desktop.schemes, ["rootline"]); + assert.equal(tauri.bundle.createUpdaterArtifacts, true); + assert.deepEqual(Object.keys(tauri.plugins.opener ?? {}).filter((key) => key !== "requireLiteralLeadingDot"), []); + assert.ok(json("apps", "desktop", "src-tauri", "capabilities", "default.json").permissions.includes("opener:default")); + + const css = read("apps", "desktop", "src", "styles.css"); + for (const token of ["--graphite-950", "--fog-50", "--cyan-500", "--moss-500", "--amber-500"]) assert.match(css, new RegExp(token)); + assert.match(css, /@media \(prefers-color-scheme: dark\)/); + assert.match(css, /@media \(prefers-reduced-motion: reduce\)/); + assert.match(css, /\.root-copy strong[^}]*ui-monospace/); + assert.deepEqual([...css.matchAll(/@keyframes\s+([\w-]+)/g)].map((match) => match[1]), ["rootline-scan"]); + assert.match(css, /\.scan-line[^}]*animation:\s*rootline-scan/); + + const readme = read("README.md"); + assert.match(readme, /one-way|source.+target/i); + assert.match(readme, /never directory trees, files, file contents, or run history/i); + assert.match(readme, /no usage telemetry/i); + const plan = read("docs", "superpowers", "plans", "2026-08-15-rootline-desktop-cli-v2.md"); + assert.match(plan, /No Linux, mobile, watcher, scheduler, mirror mode, or CLI cloud profiles/); + + const dependencyNames = Object.keys(json("apps", "desktop", "package.json").dependencies).join(" "); + assert.doesNotMatch(dependencyNames, /analytics|posthog|segment|sentry|telemetry/i); +}); + +test("Task 4 keeps Authentik, cloud privacy, profile-only sync, and reset contracts covered", () => { + const authTests = read("apps", "desktop", "src", "test", "auth.test.ts"); + for (const contract of ["openid profile email permissions offline_access", "rootline://auth/callback", "code_challenge_method", "nonce", "Storage.prototype"]) { + assert.match(authTests, new RegExp(contract.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + const strongholdTests = read("apps", "desktop", "src", "test", "stronghold-storage.test.ts"); + assert.match(strongholdTests, /OS-keyed Stronghold store/); + assert.match(strongholdTests, /fails closed/); + + const dto = read("apps", "api", "src", "sync.dto.ts"); + assert.doesNotMatch(dto, /runHistory|directoryTree|fileContents/); + const apiTests = read("apps", "api", "test", "sync.e2e.spec.ts"); + for (const coverage of [ + "wrong issuer, audience, or permission", "tenant scoped", "idempotency", "LWW", "cursor deltas", "tombstones", + "offline", "90-day receipt", "account deletion", "shared profile limits", "body, and per-user request limits", "RESET_REQUIRED", + ]) assert.match(apiTests, new RegExp(coverage, "i")); + for (const forbiddenPayload of ["directoryTree", "runHistory", "secret-source-that-must-not-be-logged"]) { + assert.match(apiTests, new RegExp(forbiddenPayload)); + } + + const hostedDocs = read("docs", "hosted-profile-sync.md"); + assert.match(hostedDocs, /Application slug[^\n]*`rootline`/i); + assert.match(hostedDocs, /TLS validation/); + assert.match(hostedDocs, /encrypted backups/i); + assert.match(hostedDocs, /Directory trees, files, file contents, and run history never enter the hosted outbox/i); +}); + +test("Task 5 keeps every build, release, updater, documentation, and portal gate represented", () => { + const ci = read(".github", "workflows", "ci.yml"); + for (const gate of ["pnpm audit", "pnpm lint", "pnpm typecheck", "pnpm test:unit", "pnpm test:api-image", "cargo fmt", "cargo clippy", "cargo test", "test:pack"]) { + assert.match(ci, new RegExp(gate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + for (const target of ["universal-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"]) assert.match(ci, new RegExp(target)); + + const release = read("docs", "release.md"); + for (const gate of ["notarization", "Authenticode", "updater", "blocked", "npm provenance"]) assert.match(release, new RegExp(gate, "i")); + assert.doesNotThrow(() => read("SECURITY.md")); + for (const document of ["privacy.md", "architecture.md", "configuration.md", "migration-v1-to-v2.md", "operations.md"]) { + assert.doesNotThrow(() => read("docs", document)); + } + + const releaseTests = read("tests", "release-workflows.test.mjs"); + assert.match(releaseTests, /desktop release requires platform signing and updater signing/i); + assert.match(releaseTests, /production image/i); + assert.match(releaseTests, /updater/i); + assert.match(read("tests", "updater-manifest.test.mjs"), /every supported runtime target/i); + + assert.match(release, /portal wording reflects actual availability/i); +}); + +test("the executable suites retain behavior-level coverage for every plan task", () => { + const suites = [ + read("packages", "core", "test", "core.test.ts"), + read("packages", "cli", "test", "node-adapter.test.ts"), + read("packages", "cli", "test", "cli.test.ts"), + read("apps", "desktop", "src", "test", "App.test.tsx"), + read("apps", "desktop", "src-tauri", "tests", "native.rs"), + read("apps", "desktop", "src", "test", "auth.test.ts"), + read("apps", "api", "test", "sync.e2e.spec.ts"), + ].join("\n"); + for (const behavior of [ + ".git", ".github", "traversal", "deterministic", "parent dependency", "overlapping", "case policy", "stale", "cancel", + "--help", "--version", "--dry-run", "--verbose", "--auto", "--config", "--json", "partial", "junction", + "50,000", "keyboard", "axe", "rebind", "Expand all", "Collapse all", "bounded[_ ]history", "outbox", "cursor", "vault", + "PKCE", "nonce", "offline", "tombstone", "rate", "RESET_REQUIRED", + ]) { + const expression = behavior === "bounded[_ ]history" + ? /bounded[_ ]history/i + : new RegExp(behavior.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); + assert.match(suites, expression); + } +}); diff --git a/tests/release-workflows.test.mjs b/tests/release-workflows.test.mjs new file mode 100644 index 0000000..a039cdd --- /dev/null +++ b/tests/release-workflows.test.mjs @@ -0,0 +1,311 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { parse } from "yaml"; + +const root = process.cwd(); +const workflows = [ + "ci.yml", + "release-npm.yml", + "release-api.yml", + "release-desktop.yml", + "release-desktop-candidate.yml", +]; + +function workflow(name) { + return readFileSync(join(root, ".github", "workflows", name), "utf8"); +} + +function parsedWorkflow(name) { + return parse(workflow(name)); +} + +function jobScopedSecrets(name) { + return Object.entries(parsedWorkflow(name).jobs).flatMap(([jobName, job]) => + Object.entries(job.env ?? {}) + .filter(([, value]) => String(value).includes("secrets.")) + .map(([key]) => `${jobName}.${key}`), + ); +} + +test("all workflows are valid YAML", () => { + for (const name of workflows) { + assert.doesNotThrow(() => parsedWorkflow(name)); + } +}); + +test("production dependency audits fail closed before CI and every release mutation", () => { + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + assert.equal(manifest.scripts["audit:prod"], "pnpm audit --prod --audit-level high"); + assert.equal(manifest.pnpm.overrides["js-yaml@>=5.0.0 <=5.2.1"], "5.2.2"); + + for (const name of workflows) { + assert.match(workflow(name), /pnpm audit --prod --audit-level high/, `${name} must enforce the production audit`); + } + const npmRelease = workflow("release-npm.yml"); + const apiRelease = workflow("release-api.yml"); + const desktopRelease = workflow("release-desktop.yml"); + assert.ok(npmRelease.indexOf("pnpm audit --prod --audit-level high") < npmRelease.indexOf("npm publish")); + assert.ok(apiRelease.indexOf("pnpm audit --prod --audit-level high") < apiRelease.indexOf("docker/build-push-action")); + assert.ok(desktopRelease.indexOf("pnpm audit --prod --audit-level high") < desktopRelease.indexOf("tauri signer sign")); + + const lockfile = readFileSync(join(root, "pnpm-lock.yaml"), "utf8"); + assert.match(lockfile, /js-yaml@5\.2\.2/); + assert.doesNotMatch(lockfile, /js-yaml@5\.2\.1/); +}); + +test("CI and API release build and health-smoke the production image before publishing it", () => { + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + assert.equal(manifest.scripts["test:api-image"], "sh apps/api/scripts/test-docker-image.sh"); + + const smoke = readFileSync(join(root, "apps", "api", "scripts", "test-docker-image.sh"), "utf8"); + for (const expected of [ + "docker build --file apps/api/Dockerfile", + "prisma migrate deploy", + "/healthz", + "JWT_JWKS_PATH", + "trap cleanup EXIT INT TERM", + ]) assert.match(smoke, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + + const ci = workflow("ci.yml"); + const release = workflow("release-api.yml"); + assert.match(ci, /pnpm test:api-image/); + assert.match(release, /pnpm test:api-image/); + assert.ok(release.indexOf("pnpm test:api-image") < release.indexOf("docker/build-push-action")); +}); + +test("every third-party action is pinned to a full immutable commit SHA", () => { + for (const name of workflows) { + const actionReferences = [...workflow(name).matchAll(/uses:\s*([\w.-]+\/[\w.-]+)@([^\s#]+)/g)]; + assert.ok(actionReferences.length > 0, `${name} must use at least one pinned action`); + for (const [, action, reference] of actionReferences) { + assert.match(reference, /^[a-f0-9]{40}$/, `${name}: ${action} must use a full 40-character commit SHA`); + } + } +}); + +test("CI covers TypeScript quality, real PostgreSQL, Rust, npm smoke, and the supported Tauri targets", () => { + const ci = workflow("ci.yml"); + for (const expected of [ + "pnpm lint", + "pnpm typecheck", + "pnpm test:unit", + "postgres:16-alpine", + "prisma:migrate:deploy", + "test:e2e", + "cargo fmt --check", + "cargo clippy", + "cargo test", + "test:pack", + "universal-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "windows-11-arm", + ]) assert.match(ci, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + const postgresSteps = parsedWorkflow("ci.yml").jobs["postgres-integration"].steps; + assert.ok(postgresSteps.some((step) => String(step.run ?? "").includes("libwebkit2gtk-4.1-dev"))); + assert.ok(postgresSteps.some((step) => String(step.run ?? "").includes("build:workspace-deps"))); + const tauriConfig = JSON.parse(readFileSync(join(root, "apps", "desktop", "src-tauri", "tauri.conf.json"), "utf8")); + assert.match(tauriConfig.build.beforeBuildCommand, /build:workspace-deps/); + const tauriSteps = parsedWorkflow("ci.yml").jobs["tauri-build"].steps; + const windowsTests = tauriSteps.find((step) => step.name === "Run Windows filesystem adapter tests"); + assert.equal(windowsTests?.if, "runner.os == 'Windows' && matrix.target == 'x86_64-pc-windows-msvc'"); + assert.match(String(windowsTests?.run), /pnpm --filter folder-structure-sync test/); + assert.match(String(windowsTests?.run), /cargo test --manifest-path apps\/desktop\/src-tauri\/Cargo\.toml/); + assert.doesNotMatch(String(windowsTests?.run), /--target/); +}); + +test("npm 2.0.0 release fails closed before provenance publishing", () => { + const release = workflow("release-npm.yml"); + for (const expected of [ + "refs/tags/v2.0.0", + "NPM_TOKEN", + "test:pack", + "npm publish", + "--provenance", + "--access public", + "sha256sum", + "actions/upload-artifact", + "actions/download-artifact", + ]) assert.match(release, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(release, /needs:\s*preflight/); + assert.match(release, /if \[ "\$\{GITHUB_REF\}" != "refs\/tags\/v2\.0\.0" \]/); + assert.equal(parsedWorkflow("release-npm.yml").jobs.preflight.environment, "npm-production"); + assert.match(release, /protected npm-production environment secret/); + assert.doesNotMatch(release, /repository secret/); + const releaseDocs = readFileSync(join(root, "docs", "release.md"), "utf8"); + assert.match(releaseDocs, /protected `npm-production` environment secret `NPM_TOKEN`/); + assert.match(releaseDocs, /Never configure this credential as a repository secret/); + assert.deepEqual(jobScopedSecrets("release-npm.yml"), []); + const npmJobs = parsedWorkflow("release-npm.yml").jobs; + assert.equal(npmJobs.pack.permissions["id-token"], undefined); + assert.equal(npmJobs.pack.outputs.checksum, "${{ steps.checksum.outputs.sha256 }}"); + assert.doesNotMatch(JSON.stringify(npmJobs.publish.steps), /pnpm install|actions\/checkout/); + const publishSetup = npmJobs.publish.steps.find((step) => String(step.uses ?? "").includes("actions/setup-node")); + assert.equal(publishSetup.with["registry-url"], "https://registry.npmjs.org"); + assert.equal(publishSetup.with.cache, undefined); + const publishStep = npmJobs.publish.steps.find((step) => String(step.run ?? "").includes("npm publish")); + assert.equal(publishStep.env.NODE_AUTH_TOKEN, "${{ secrets.NPM_TOKEN }}"); + const manifest = JSON.parse(readFileSync(join(root, "packages", "cli", "package.json"), "utf8")); + assert.deepEqual(manifest.repository, { + type: "git", + url: "git+https://github.com/unique01082/folder-structure-sync.git", + }); + assert.equal(manifest.engines.node, ">=20"); +}); + +test("only the bundled CLI is a public npm workspace package", () => { + const contracts = JSON.parse(readFileSync(join(root, "packages", "contracts", "package.json"), "utf8")); + const core = JSON.parse(readFileSync(join(root, "packages", "core", "package.json"), "utf8")); + const cli = JSON.parse(readFileSync(join(root, "packages", "cli", "package.json"), "utf8")); + + assert.equal(contracts.private, true); + assert.equal(core.private, true); + assert.notEqual(cli.private, true); + assert.match(contracts.description, /Internal/); +}); + +test("API release gates registry, migration, deployment, and HTTPS health secrets", () => { + const release = workflow("release-api.yml"); + for (const expected of [ + "ROOTLINE_API_DATABASE_URL", + "ROOTLINE_API_DEPLOY_WEBHOOK_URL", + "ROOTLINE_API_BASE_URL", + "ROOTLINE_JWT_JWKS_B64", + "sslaccept", + "prisma migrate deploy", + "/healthz", + "docker/build-push-action", + "Refuse to overwrite stable image tag", + ]) assert.match(release, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(release, /needs:\s*preflight/); + assert.match(release, /refs\/tags\/v2\.0\.0/); + assert.match(release, /steps\.build\.outputs\.digest/); + assert.match(release, /image=.*@\$\{DIGEST\}/); + assert.match(release, /ROOTLINE_BUILD_ID/); + assert.match(release, /EXPECTED_BUILD_ID/); + assert.match(release, /\.buildId == env\.EXPECTED_BUILD_ID/); + assert.match(release, /sslmode"\) !== "require"/); + assert.match(release, /sslaccept"\) !== "strict"/); + assert.equal(parsedWorkflow("release-api.yml").jobs.preflight.environment, "api-production"); + assert.deepEqual(jobScopedSecrets("release-api.yml"), []); + const apiJobs = parsedWorkflow("release-api.yml").jobs; + assert.deepEqual(apiJobs.promote.needs, ["image", "health"]); + assert.match(release, /candidate-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); + assert.match(JSON.stringify(apiJobs.promote.steps), /imagetools create --tag/); + const dockerfile = readFileSync(join(root, "apps", "api", "Dockerfile"), "utf8"); + assert.match(dockerfile, /^FROM node:20-bookworm-slim@sha256:[a-f0-9]{64} AS base$/m); +}); + +test("desktop release requires platform signing and updater signing for every stable artifact", () => { + const release = workflow("release-desktop.yml"); + for (const expected of [ + "APPLE_CERTIFICATE", + "APPLE_CERTIFICATE_PASSWORD", + "APPLE_SIGNING_IDENTITY", + "APPLE_ID", + "APPLE_PASSWORD", + "APPLE_TEAM_ID", + "WINDOWS_CERTIFICATE", + "WINDOWS_CERTIFICATE_PASSWORD", + "TAURI_SIGNING_PRIVATE_KEY", + "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", + "TAURI_UPDATER_PUBLIC_KEY", + "universal-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "latest.json", + ]) assert.match(release, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch(release, /TAURI_SIGNING_PRIVATE_KEY:\s*["']?["']?\s*$/m); + assert.doesNotMatch(release, /adhoc|ad-hoc|disable.*sign/i); + assert.doesNotMatch(release, /nsis\.zip/); + assert.match(release, /setup\.exe\.sig/); + assert.match(release, /needs:\s*preflight/); + const desktopJobs = parsedWorkflow("release-desktop.yml").jobs; + assert.equal(desktopJobs.preflight.environment, "desktop-production"); + assert.equal(desktopJobs["macos-universal"].needs, "preflight"); + assert.equal(desktopJobs.windows.needs, "preflight"); + assert.deepEqual(desktopJobs["publish-release"].needs, ["preflight", "macos-universal", "windows"]); + assert.deepEqual(jobScopedSecrets("release-desktop.yml"), []); + const preflight = JSON.stringify(parsedWorkflow("release-desktop.yml").jobs.preflight.steps); + assert.match(preflight, /tauri signer sign/); + assert.match(preflight, /minisign -Vm/); + assert.match(preflight, /base64 --decode/); + assert.match(preflight, /private key, password, and public key do not form one updater keypair/); + assert.doesNotMatch(preflight, /continue-on-error/); +}); + +test("desktop candidate workflow signs internal artifacts and can publish only an isolated prerelease", () => { + const name = "release-desktop-candidate.yml"; + const candidate = workflow(name); + const parsed = parsedWorkflow(name); + + assert.deepEqual(Object.keys(parsed.on), ["workflow_dispatch"]); + assert.equal(parsed.jobs.preflight.environment, "desktop-production"); + assert.equal(parsed.jobs.preflight.steps.some((step) => String(step.run ?? "").includes("refs/heads/master")), true); + assert.match(candidate, /build-rootline-candidate/); + assert.ok(candidate.includes("grep -Eq '^v2[.]0[.]0-beta[.][0-9]+$'")); + assert.match(candidate, /pnpm audit --prod --audit-level high/); + for (const required of [ + "APPLE_CERTIFICATE", + "WINDOWS_CERTIFICATE", + "TAURI_SIGNING_PRIVATE_KEY", + "TAURI_UPDATER_PUBLIC_KEY", + "VITE_AUTHENTIK_ISSUER", + "VITE_ROOTLINE_SYNC_API", + "universal-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "tauri signer sign", + "minisign -Vm", + "--prerelease", + "--target", + ]) assert.match(candidate, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.deepEqual(parsed.jobs["publish-beta"].needs, ["preflight", "macos-universal", "windows"]); + assert.equal(parsed.jobs["publish-beta"].if, "inputs.channel == 'public-beta'"); + assert.equal(parsed.jobs["publish-beta"].permissions.contents, "write"); + assert.deepEqual(jobScopedSecrets(name), []); + assert.match(candidate, /CANDIDATE_VERSION="\$\{BETA_TAG#v\}"/); + assert.match(candidate, /Substring\(1\)/); + assert.doesNotMatch(candidate, /create-updater-manifest|release-assets\/latest\.json/); + assert.doesNotMatch(candidate, /refs\/tags\/v2\.0\.0(?:[^-]|$)/m); + assert.doesNotMatch(candidate, /npm publish|docker\/build-push-action/); + + const releaseDocs = readFileSync(join(root, "docs", "release.md"), "utf8"); + assert.match(releaseDocs, /release-desktop-candidate\.yml/); + assert.match(releaseDocs, /internal.+public beta.+stable/is); + assert.match(releaseDocs, /does not publish `latest\.json`/i); +}); + +test("desktop updater is registered and serves every default runtime target", () => { + const config = JSON.parse(readFileSync(join(root, "apps", "desktop", "src-tauri", "tauri.conf.json"), "utf8")); + const cargo = readFileSync(join(root, "apps", "desktop", "src-tauri", "Cargo.toml"), "utf8"); + const rust = readFileSync(join(root, "apps", "desktop", "src-tauri", "src", "lib.rs"), "utf8"); + const capability = JSON.parse(readFileSync(join(root, "apps", "desktop", "src-tauri", "capabilities", "default.json"), "utf8")); + const manifest = readFileSync(join(root, "scripts", "create-updater-manifest.mjs"), "utf8"); + + assert.equal(config.bundle.createUpdaterArtifacts, true); + assert.deepEqual(config.plugins.updater.endpoints, [ + "https://github.com/unique01082/folder-structure-sync/releases/latest/download/latest.json", + ]); + assert.match(cargo, /tauri-plugin-updater/); + assert.match(rust, /tauri_plugin_updater::Builder::new\(\)\.build\(\)/); + assert.ok(capability.permissions.includes("updater:default")); + for (const target of ["darwin-aarch64", "darwin-x86_64", "windows-x86_64", "windows-aarch64"]) { + assert.match(manifest, new RegExp(`\\"${target}\\"`)); + } +}); + +test("release and privacy docs describe the real ordering and lazy receipt cleanup", () => { + const release = readFileSync(join(root, "docs", "release.md"), "utf8"); + const privacy = readFileSync(join(root, "docs", "privacy.md"), "utf8"); + const hostedSync = readFileSync(join(root, "docs", "hosted-profile-sync.md"), "utf8"); + const migrationIndex = release.indexOf("applies the checked-in migrations"); + const deploymentIndex = release.indexOf("calls the HTTPS deployment webhook"); + + assert.ok(migrationIndex >= 0 && deploymentIndex >= 0 && migrationIndex < deploymentIndex); + assert.match(privacy, /eligible for cleanup after 90 days/i); + assert.match(privacy, /opportunistically on a later sync/i); + assert.match(hostedSync, /Application slug[^\n]*`rootline`/i); +}); diff --git a/tests/updater-manifest.test.mjs b/tests/updater-manifest.test.mjs new file mode 100644 index 0000000..66ccad2 --- /dev/null +++ b/tests/updater-manifest.test.mjs @@ -0,0 +1,39 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +test("builds a complete updater manifest for every supported runtime target", (context) => { + const directory = mkdtempSync(join(tmpdir(), "rootline-updater-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const artifacts = [ + ["rootline-2.0.0-darwin-universal.app.tar.gz", "signature-darwin-universal"], + ["rootline-2.0.0-windows-x86_64-setup.exe", "signature-windows-x86_64"], + ["rootline-2.0.0-windows-aarch64-setup.exe", "signature-windows-aarch64"], + ]; + for (const [name, signature] of artifacts) { + writeFileSync(join(directory, name), "artifact"); + writeFileSync(join(directory, `${name}.sig`), signature); + } + const output = join(directory, "latest.json"); + + execFileSync(process.execPath, ["scripts/create-updater-manifest.mjs", directory, "https://github.com/example/rootline/releases/download/v2.0.0", output], { + cwd: process.cwd(), + env: { ...process.env, RELEASE_PUBLISHED_AT: "2026-08-15T00:00:00.000Z" }, + }); + + const manifest = JSON.parse(readFileSync(output, "utf8")); + assert.equal(manifest.version, "2.0.0"); + assert.equal(manifest.pub_date, "2026-08-15T00:00:00.000Z"); + assert.deepEqual(Object.keys(manifest.platforms).sort(), [ + "darwin-aarch64", + "darwin-x86_64", + "windows-aarch64", + "windows-x86_64", + ]); + assert.match(manifest.platforms["windows-aarch64"].url, /rootline-2\.0\.0-windows-aarch64-setup\.exe$/); + assert.equal(manifest.platforms["darwin-aarch64"].signature, "signature-darwin-universal"); + assert.equal(manifest.platforms["darwin-x86_64"].signature, "signature-darwin-universal"); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..e367d75 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true + } +} diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 0000000..44729b8 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,6 @@ +import { defineWorkspace } from "vitest/config"; + +export default defineWorkspace([ + "packages/*/vitest.config.ts", + "apps/*/vitest.config.ts", +]);