diff --git a/.github/ci/minio/Dockerfile b/.github/ci/minio/Dockerfile new file mode 100644 index 0000000000..79fe0e8e35 --- /dev/null +++ b/.github/ci/minio/Dockerfile @@ -0,0 +1,16 @@ +# CI-only AMD64 image. Versions match the development Compose services. +# Upstream registries deny pulls; official release binaries remain available. +FROM alpine:3.22.6@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 + +LABEL org.opencontainers.image.source="https://github.com/block/buzz" \ + org.opencontainers.image.title="Buzz CI MinIO" \ + org.opencontainers.image.licenses="AGPL-3.0-only" + +RUN apk add --no-cache ca-certificates curl + +ADD --checksum=sha256:7c5bd8512c6e966455b1d198209358b2d191c77a83ab377c4073281065fb855f --chmod=755 \ + https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-amd64.RELEASE.2025-09-07T16-13-09Z /usr/local/bin/minio +ADD --checksum=sha256:01f866e9c5f9b87c2b09116fa5d7c06695b106242d829a8bb32990c00312e891 --chmod=755 \ + https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-amd64.RELEASE.2025-08-13T08-35-41Z /usr/local/bin/mc + +ENTRYPOINT ["minio"] diff --git a/.github/ci/minio/README.md b/.github/ci/minio/README.md new file mode 100644 index 0000000000..51bf7c70a2 --- /dev/null +++ b/.github/ci/minio/README.md @@ -0,0 +1,51 @@ +# Buzz CI MinIO + +`ghcr.io/block/buzz-minio:latest` contains MinIO and `mc` for the disposable +Linux AMD64 CI runners. `docker-compose.ci.yml` selects it for both services; +development and deployment defaults stay in `docker-compose.yml`. + +The **MinIO image** workflow builds only when its inputs change, or on a manual +dispatch. Pull requests build and smoke-test without publishing. On `main`, a +successful smoke test publishes the same image as +`sha--run--` and `latest`. The run-specific tag +preserves each build, including package refreshes from the same source commit. +Once consumers adopt the override, ordinary CI only pulls it; there is no build +fallback or dependency on the publisher. `latest` deliberately floats, and +Compose always pulls it. Docker's pull output records the resolved digest. + +The Dockerfile uses the same upstream releases as the development services, +with checksummed official GitHub release binaries and a digest-pinned Alpine +base. MinIO and `mc` are AGPL-3.0; their corresponding source is available at +the [MinIO release](https://github.com/minio/minio/tree/RELEASE.2025-09-07T16-13-09Z) +and [mc release](https://github.com/minio/mc/tree/RELEASE.2025-08-13T08-35-41Z). + +## First publication + +The image must exist and be publicly pullable before the CI switch can pass. +Use two separate PRs: Buzz only permits squash merges, so two commits in one +PR cannot stage this rollout. + +1. Merge the publisher-only PR containing `.github/ci/minio/`, + `.github/workflows/minio-image.yml`, and the opt-in `docker-compose.ci.yml`. + Ordinary CI does not select the override yet, so it does not need this image + to validate the publisher PR. The merge triggers the first publication. +2. After publication succeeds, an org/package admin must make **buzz-minio** + public in its GitHub package settings (new GHCR packages default to private, + even for public repositories). Verify an anonymous pull of + `ghcr.io/block/buzz-minio:latest`. +3. Rebase the separate consumer PR onto `main`, run its integration checks, and + merge it. That PR selects the override in relay and mesh lifecycle CI. + +Subsequent publications preserve package visibility. + +## Updating or rebuilding + +Update the release URLs/checksums or base digest in the Dockerfile and open a +PR. Merging triggers publication. To rebuild the existing recipe (for example, +to pick up Alpine package updates), dispatch **MinIO image** from `main`. +Manual dispatch disables Docker's layer cache so the package installation +runs again. Other refs can build and test but cannot publish `latest`. + +To reproduce a CI run with a recorded version, set `MINIO_CI_IMAGE` to +the published run-specific tag or digest while using +`COMPOSE_FILE=docker-compose.yml:docker-compose.ci.yml`. diff --git a/.github/ci/minio/smoke-test.sh b/.github/ci/minio/smoke-test.sh new file mode 100644 index 0000000000..c04f1c0cf1 --- /dev/null +++ b/.github/ci/minio/smoke-test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Run on a disposable Docker host with the CI Compose files and a built image. +set -euo pipefail +: "${MINIO_CI_IMAGE:?Set MINIO_CI_IMAGE to the locally built image}" +: "${COMPOSE_FILE:?Select docker-compose.yml and docker-compose.ci.yml}" + +# Exercise the real healthcheck and initializer without pulling over the image +# under test. No application services or tests need a MinIO build step. +docker compose up -d --wait --wait-timeout 90 --pull never minio +docker compose run --rm --no-deps --pull never minio-init +docker compose exec -T minio sh -eu -c ' + minio --version + mc --version + mc alias set local http://localhost:9000 buzz_dev buzz_dev_secret + printf "buzz-minio-smoke\n" > /tmp/expected + mc cp /tmp/expected local/buzz-media/smoke-test + mc cat local/buzz-media/smoke-test > /tmp/actual + cmp /tmp/expected /tmp/actual + status=$(curl --silent --show-error --output /dev/null --write-out "%{http_code}" \ + http://localhost:9000/buzz-media/smoke-test) + test "$status" = 403 + mc rm local/buzz-media/smoke-test + if mc stat local/buzz-media/smoke-test; then + echo "Deleted object is still present" >&2 + exit 1 + fi +' diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 3d7bc6e35c..b616b8e7f3 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -207,6 +207,8 @@ jobs: --archive-file target/ci/postgres-tests.tar.zst desktop-e2e-integration-shard: + env: + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml name: Desktop E2E Integration (${{ matrix.shard }}/2) runs-on: ubuntu-latest timeout-minutes: 20 @@ -400,6 +402,8 @@ jobs: echo "Desktop E2E Integration shards passed" backend-integration: + env: + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml name: Backend Integration (relay e2e) runs-on: ubuntu-latest timeout-minutes: 20 @@ -562,6 +566,8 @@ jobs: if-no-files-found: ignore relay-e2e: + env: + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml name: Relay E2E runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b93f32f1a..94336011cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,8 @@ jobs: - 'deny.toml' - '.github/workflows/ci.yml' - '.github/workflows/_ci-*.yml' + - 'docker-compose.yml' + - 'docker-compose.ci.yml' - 'scripts/run-tests.sh' - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml index c699439980..0666e98c32 100644 --- a/.github/workflows/mesh-lifecycle.yml +++ b/.github/workflows/mesh-lifecycle.yml @@ -12,7 +12,7 @@ on: push: branches: [main] paths: - - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-mesh-smoke/**' - 'crates/buzz-relay/Cargo.toml' - 'crates/buzz-admin/**' - 'crates/buzz-test-client/**' @@ -22,9 +22,11 @@ on: - 'scripts/ci-mesh-lifecycle-smoke.sh' - 'scripts/start-relay-for-tests.sh' - '.github/workflows/mesh-lifecycle.yml' + - 'docker-compose.yml' + - 'docker-compose.ci.yml' pull_request: paths: - - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-mesh-smoke/**' - 'crates/buzz-relay/Cargo.toml' - 'crates/buzz-admin/**' - 'crates/buzz-test-client/**' @@ -34,6 +36,8 @@ on: - 'scripts/ci-mesh-lifecycle-smoke.sh' - 'scripts/start-relay-for-tests.sh' - '.github/workflows/mesh-lifecycle.yml' + - 'docker-compose.yml' + - 'docker-compose.ci.yml' workflow_dispatch: concurrency: @@ -45,6 +49,8 @@ env: jobs: lifecycle-smoke: + env: + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml name: Relay-Driven Mesh Lifecycle Smoke runs-on: ubuntu-24.04 timeout-minutes: 45 diff --git a/.github/workflows/minio-image.yml b/.github/workflows/minio-image.yml new file mode 100644 index 0000000000..5a9e970dc6 --- /dev/null +++ b/.github/workflows/minio-image.yml @@ -0,0 +1,80 @@ +name: MinIO image + +# A separate publisher, never a dependency of ordinary CI. Image changes get +# a build/smoke test on PRs; only reviewed main commits can move :latest. +on: + push: + branches: [main] + paths: + - '.github/ci/minio/**' + - '.github/workflows/minio-image.yml' + - 'docker-compose.yml' + - 'docker-compose.ci.yml' + pull_request: + paths: + - '.github/ci/minio/**' + - '.github/workflows/minio-image.yml' + - 'docker-compose.yml' + - 'docker-compose.ci.yml' + workflow_dispatch: + +concurrency: + group: minio-image-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + IMAGE_NAME: ghcr.io/block/buzz-minio + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml + MINIO_CI_IMAGE: ghcr.io/block/buzz-minio:sha-${{ github.sha }}-run-${{ github.run_id }}-${{ github.run_attempt }} + +jobs: + image: + name: Build and smoke-test MinIO + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - name: Build image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: .github/ci/minio + platforms: linux/amd64 + load: true + tags: ${{ env.MINIO_CI_IMAGE }} + labels: org.opencontainers.image.revision=${{ github.sha }} + # A maintenance dispatch must refresh apk packages even with a warm + # layer cache. Normal image changes can still reuse cached layers. + no-cache: ${{ github.event_name == 'workflow_dispatch' }} + cache-from: type=gha,scope=minio + cache-to: type=gha,scope=minio,mode=max + - name: Smoke-test CI services + run: bash .github/ci/minio/smoke-test.sh + - name: Clean up smoke test + if: always() + run: docker compose down --volumes + - name: Log in to GHCR + if: github.repository == 'block/buzz' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish tested image + if: github.repository == 'block/buzz' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + run: | + docker push "$MINIO_CI_IMAGE" + docker tag "$MINIO_CI_IMAGE" "$IMAGE_NAME:latest" + docker push "$IMAGE_NAME:latest" + { + echo "Published $MINIO_CI_IMAGE and $IMAGE_NAME:latest" + echo 'First publication: make the buzz-minio package public before enabling CI consumers.' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index aeda684096..92fc6a37ce 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.24", - "base_sha": "fe56d311a7156802408afb04023c3f75d4015007", - "previous_tag": "desktop-v0.5.23", - "previous_base_sha": "dad5a33865fc81a2e55b3b60746632f615ec1e3a", - "previous_merge_sha": "3c7f288c60d67df78577b237e27c3dfc8831aaa1", - "tag": "desktop-v0.5.24", - "commit_count": 75 + "version": "0.5.25", + "base_sha": "797012ff01a6d499959b45ed2e56f7927c6a4d6b", + "previous_tag": "desktop-v0.5.24", + "previous_base_sha": "fe56d311a7156802408afb04023c3f75d4015007", + "previous_merge_sha": "ec7ea38f62ea917f15e85a678bc94f3bbee5bb64", + "tag": "desktop-v0.5.25", + "commit_count": 17 } diff --git a/AGENTS.md b/AGENTS.md index 58309c0865..3c9d578bb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,48 @@ and runtime evidence answer different questions. --- +## Before opening a PR + +Open work in progress as a draft PR. Mark it ready for review only when this +checklist holds for the current change. Scale it to what changed: +documentation-only changes need content, link, and diff checks plus human +confirmation, not app runs. + +1. **Agent review ran** under [Reviewing](#reviewing), and its recommended + blockers were fixed or explicitly declined by the human author. Optional + suggestions do not gate readiness. +2. **An agent exercised the changed behavior.** Client changes: the affected flow + in the app, using the native app or a device when the behavior needs it + (browser or headless Playwright counts only for what it can exercise). Relay + changes: a local relay, exercising the changed events or endpoints. CLI or + tooling changes: the affected command or workflow. The human may skip this + step, for example for a small change or while iterating. +3. **A human then tested it themselves**: in the app, against the local relay + (through the app or `curl`), or by running the changed command. Agent testing + does not substitute. Agents give the human exact steps and what working looks + like, then wait for explicit confirmation. Never mark this step done yourself. +4. **Add `buzz-review-completed` to the PR description** once steps 1–3 hold. It + attests the checklist, and automated reviewers may skip review because of it. + If later edits change behavior, remove it and return the PR to draft until the + affected steps are redone. + +## Reviewing + +- Before reviewing, read [VISION.md](VISION.md), the `VISION_*.md` docs for the affected surface, and the PR's stated goal and linked + issue. Review the change against what it is trying to do. +- Check the change against the [Review-Proven Rules](#review-proven-rules): the defects reviewers here find most often. +- Judge minimalism, elegance, and correctness, aiming for 9/10 on each. A score + below 9 names the concrete defect and the fix. +- Recommend blocking only for concrete correctness, security, or agreed-contract + defects with a realistic failure scenario: state the defect, how it fails, and + the fix. Label everything else (nits, wording, speculative hardening, + out-of-scope improvements) as optional. +- Put all findings in the first review. Later reviews check prior blockers and + defects the fixes introduced; reopen other areas only on new evidence of a + material defect. +- Agents post reviews as comments, never Request Changes. Humans decide which + findings must be fixed. + ## Ecosystem Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment: @@ -165,8 +207,8 @@ the same clusters and measured how often authors actually fix each class once flagged: test-seam binding and unbounded-resource findings were fixed **100%** of the time, swallowed-error findings **90%**, stale-state races **70%** — these are not style opinions, they are defects authors agree -with on sight. Apply the rules **before writing code**; each cites the -PRs where reviewers litigated it. +with on sight. Authors apply them **before writing code**, and reviewers check +against them; each cites the PRs where reviewers litigated it. 1. **Every caught failure must leave a durable retry record or propagate.** Never catch-log-and-return-success (opt-out revocation permanently diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a61fb5aa..f4249ef5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v0.5.25 + +### Desktop and shared changes + +- Discover alternate Buzz ACP commands ([#6948](https://github.com/block/buzz/pull/6948)) ([`dd1c2afc2a16238656c5ed19fbfc558605248163`](https://github.com/block/buzz/commit/dd1c2afc2a16238656c5ed19fbfc558605248163)) +- chore(mesh): upgrade MeshLLM to 0.76.2 ([#7559](https://github.com/block/buzz/pull/7559)) ([`4544ba1713d8d7f5959d7d63886ff3927357e1ac`](https://github.com/block/buzz/commit/4544ba1713d8d7f5959d7d63886ff3927357e1ac)) +- feat(agents): humanize uncurated Databricks model ids with a label grammar ([#7844](https://github.com/block/buzz/pull/7844)) ([`d01e5f82058463709a22e93bb4cd795da5f53e10`](https://github.com/block/buzz/commit/d01e5f82058463709a22e93bb4cd795da5f53e10)) +- fix: route databricks claude fqns to anthropic messages ([#7829](https://github.com/block/buzz/pull/7829)) ([`48884848f566d02c42ce07636636c4ad5f164c27`](https://github.com/block/buzz/commit/48884848f566d02c42ce07636636c4ad5f164c27)) +- feat(relay): add opt-in newest-first thread windows ([#7823](https://github.com/block/buzz/pull/7823)) ([`5a349db91c23fa70c8c8aa43a3a5ba0005d8dd41`](https://github.com/block/buzz/commit/5a349db91c23fa70c8c8aa43a3a5ba0005d8dd41)) +- refactor: move agent Git bootstrap into ACP harness ([#7819](https://github.com/block/buzz/pull/7819)) ([`5621006bcf84b82e5da489824a5b4d76568d8602`](https://github.com/block/buzz/commit/5621006bcf84b82e5da489824a5b4d76568d8602)) +- fix(agent): preserve Databricks Opus UC reasoning and tool continuation ([#7840](https://github.com/block/buzz/pull/7840)) ([`0cc63fe3e607cf47ff32bc61e7eaca2f21e6cc23`](https://github.com/block/buzz/commit/0cc63fe3e607cf47ff32bc61e7eaca2f21e6cc23)) +- feat(canvas): add version history with atomic restore ([#6780](https://github.com/block/buzz/pull/6780)) ([`b6a26556ec904cdebe0fbc7e6c25b4ea2cf294d1`](https://github.com/block/buzz/commit/b6a26556ec904cdebe0fbc7e6c25b4ea2cf294d1)) + +### Other repository changes + +- fix(ci): consume the published MinIO image ([#7870](https://github.com/block/buzz/pull/7870)) ([`797012ff01a6d499959b45ed2e56f7927c6a4d6b`](https://github.com/block/buzz/commit/797012ff01a6d499959b45ed2e56f7927c6a4d6b)) +- fix(mobile): converge sidebar managers on relay head with resume re-read ([#7806](https://github.com/block/buzz/pull/7806)) ([`309e34d80f8c1f8524df089674f951f524c50119`](https://github.com/block/buzz/commit/309e34d80f8c1f8524df089674f951f524c50119)) +- fix(ci): bootstrap the reusable MinIO image in GHCR ([#7869](https://github.com/block/buzz/pull/7869)) ([`99c2acf90cfbb1cb2d3a8bd900c0ec1642e20540`](https://github.com/block/buzz/commit/99c2acf90cfbb1cb2d3a8bd900c0ec1642e20540)) +- fix(hooks): surface nextest failures and stale pnpm deps in pre-push ([#7850](https://github.com/block/buzz/pull/7850)) ([`611faf7511c2f7ba564437daea18987db83fd82b`](https://github.com/block/buzz/commit/611faf7511c2f7ba564437daea18987db83fd82b)) +- feat(acp): run one prepared task from a file or stdin ([#7851](https://github.com/block/buzz/pull/7851)) ([`ea1e97e65f816a1f4d92f2da6e3498eb90f5d361`](https://github.com/block/buzz/commit/ea1e97e65f816a1f4d92f2da6e3498eb90f5d361)) +- Fix mobile heart and warning emoji with native font fallback ([#7842](https://github.com/block/buzz/pull/7842)) ([`9462d8884a78147cf277988acb254af81f3df7f9`](https://github.com/block/buzz/commit/9462d8884a78147cf277988acb254af81f3df7f9)) +- Use worker snapshots for relay storage metrics ([#7845](https://github.com/block/buzz/pull/7845)) ([`4142d2b84e1c3230fbe6c4b2d1e994721c409092`](https://github.com/block/buzz/commit/4142d2b84e1c3230fbe6c4b2d1e994721c409092)) +- fix(hooks): strip repo-local git env from pre-push test lanes ([#7841](https://github.com/block/buzz/pull/7841)) ([`3b2e50b15c6afba3ff3b8fbe1bdf0a7a69d29f7b`](https://github.com/block/buzz/commit/3b2e50b15c6afba3ff3b8fbe1bdf0a7a69d29f7b)) +- fix(mobile-infra): render push grant lifetimes as decimal in chart 0.3.2 ([#7820](https://github.com/block/buzz/pull/7820)) ([`0ef7a2222ef4e9b221f310ac60ca6a535d9c840d`](https://github.com/block/buzz/commit/0ef7a2222ef4e9b221f310ac60ca6a535d9c840d)) + +[Compare desktop-v0.5.24...desktop-v0.5.25](https://github.com/block/buzz/compare/desktop-v0.5.24...desktop-v0.5.25) + ## v0.5.24 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index a92813aacc..fd40f824ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -25,19 +35,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if 1.0.4", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -117,7 +138,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -128,7 +149,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -155,6 +176,17 @@ dependencies = [ "rustls-webpki", ] +[[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 = "approx" version = "0.5.1" @@ -164,15 +196,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arc-swap" version = "1.9.2" @@ -190,14 +213,14 @@ checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", - "password-hash", + "cpufeatures 0.3.0", + "password-hash 0.6.1", ] [[package]] @@ -287,6 +310,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + [[package]] name = "async-io" version = "2.6.0" @@ -300,7 +337,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -331,7 +368,7 @@ dependencies = [ "cfg-if 1.0.4", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -357,7 +394,7 @@ dependencies = [ "cfg-if 1.0.4", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -559,7 +596,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite 0.29.0", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -630,6 +667,12 @@ 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" @@ -765,11 +808,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -813,6 +856,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -857,7 +909,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -866,6 +918,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -888,6 +949,7 @@ name = "buzz-acp" version = "0.1.0" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "buzz-core", "buzz-persona", @@ -912,7 +974,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "tokio-util", - "toml 1.1.2+spec-1.1.0", + "toml", "tracing", "tracing-subscriber", "url", @@ -962,7 +1024,7 @@ dependencies = [ "nix 0.31.3", "rcgen", "reqwest 0.13.4", - "rmcp", + "rmcp 1.8.0", "serde", "serde_json", "serde_yaml", @@ -1037,7 +1099,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tokio", - "tower", + "tower 0.5.3", ] [[package]] @@ -1177,9 +1239,9 @@ dependencies = [ "nix 0.31.3", "nostr 0.44.7", "reqwest 0.13.4", - "rmcp", + "rmcp 1.8.0", "rustls", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "similar", @@ -1191,6 +1253,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "buzz-feature-flags" +version = "0.1.0" +dependencies = [ + "buzz-core", + "http", + "launchdarkly-server-sdk", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "buzz-media" version = "0.1.0" @@ -1222,6 +1297,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-mesh-smoke" +version = "0.1.0" +dependencies = [ + "anyhow", + "buzz-test-client", + "ed25519-dalek", + "hex", + "mesh-llm-host-runtime", + "mesh-llm-sdk", + "nostr 0.44.7", + "reqwest 0.13.4", + "serde_json", + "sha2 0.11.0", + "tokio", + "uuid", +] + [[package]] name = "buzz-pair-relay" version = "0.1.0" @@ -1312,7 +1405,7 @@ dependencies = [ "sqlx", "thiserror 2.0.18", "tokio", - "tower", + "tower 0.5.3", "tower-http", "tracing", "tracing-subscriber", @@ -1341,21 +1434,17 @@ dependencies = [ "buzz-relay-mesh", "buzz-sdk", "buzz-search", - "buzz-test-client", "buzz-workflow", "bytes", "chrono", "dashmap", "deadpool-redis", - "ed25519-dalek", "flate2", "futures", "futures-util", "hex", "hmac 0.13.0", "infer", - "mesh-llm-host-runtime", - "mesh-llm-sdk", "metrics", "metrics-exporter-prometheus", "metrics-util", @@ -1382,7 +1471,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tracing", "tracing-opentelemetry", @@ -1593,7 +1682,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -1650,7 +1748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if 1.0.4", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -1661,6 +1759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if 1.0.4", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -1671,13 +1770,25 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", - "cipher", - "poly1305", + "cipher 0.4.4", + "poly1305 0.8.0", "zeroize", ] +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20 0.10.2", + "cipher 0.5.2", + "poly1305 0.9.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1719,6 +1830,12 @@ dependencies = [ "half", ] +[[package]] +name = "cidr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" + [[package]] name = "cipher" version = "0.4.4" @@ -1726,10 +1843,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.6" @@ -1812,7 +1940,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2016,6 +2144,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2119,22 +2253,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags 2.13.0", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.29.0" @@ -2147,7 +2265,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix 1.1.4", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -2197,7 +2315,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -2206,7 +2326,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009" dependencies = [ - "aead", + "aead 0.5.2", "crypto_secretbox", "curve25519-dalek 4.1.3", "salsa20", @@ -2220,10 +2340,10 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" dependencies = [ - "aead", - "cipher", + "aead 0.5.2", + "cipher 0.4.4", "generic-array", - "poly1305", + "poly1305 0.8.0", "salsa20", "subtle", "zeroize", @@ -2255,7 +2375,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -2284,9 +2404,9 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "5.0.0-rc.0" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.3.0", @@ -2331,6 +2451,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2358,6 +2488,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -2380,6 +2523,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "dary_heap" version = "0.3.9" @@ -2426,37 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", -] - -[[package]] -name = "dbus" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "dbus-secret-service" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "aes", - "block-padding", - "cbc", - "dbus", - "fastrand", - "hkdf 0.12.4", - "num", - "once_cell", - "openssl", - "sha2 0.10.9", - "zeroize", + "syn 2.0.117", ] [[package]] @@ -2489,6 +2613,37 @@ dependencies = [ "tokio", ] +[[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.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deltae" version = "0.3.2" @@ -2541,17 +2696,6 @@ 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.117", -] - [[package]] name = "derive_builder" version = "0.20.2" @@ -2663,7 +2807,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2763,11 +2907,11 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "3.0.0-rc.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek 5.0.0-rc.0", + "curve25519-dalek 5.0.0", "ed25519", "rand_core 0.10.1", "serde", @@ -2884,7 +3028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2939,6 +3083,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-client" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96df8cfa11d3c8e4e1a48b81c9c600ecbe8c11d1326f41e1524cc4d42f7bf6d9" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "http", + "launchdarkly-sdk-transport", + "log", + "pin-project", + "rand 0.10.1", + "tokio", +] + [[package]] name = "evmap" version = "11.0.0" @@ -3095,17 +3256,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", -] - -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin 0.9.9", + "zlib-rs", ] [[package]] @@ -3322,7 +3473,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -3487,7 +3638,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -3520,6 +3671,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" +[[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" @@ -3544,6 +3701,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -3557,15 +3716,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "hashlink" version = "0.11.0" @@ -3575,6 +3725,30 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1 0.10.6", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heapify" version = "0.2.0" @@ -3729,15 +3903,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac 0.12.1", -] - [[package]] name = "hkdf" version = "0.13.0" @@ -3862,6 +4027,25 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-http-proxy" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -3893,22 +4077,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -4160,6 +4328,17 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[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" @@ -4168,6 +4347,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -4194,10 +4375,20 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -4302,7 +4493,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" dependencies = [ - "curve25519-dalek 5.0.0-rc.0", + "curve25519-dalek 5.0.0", "data-encoding", "data-encoding-macro", "derive_more", @@ -4386,7 +4577,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru", + "lru 0.18.0", "n0-error", "n0-future", "noq", @@ -4432,6 +4623,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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" @@ -4584,19 +4829,23 @@ dependencies = [ [[package]] name = "keyring" -version = "3.6.3" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +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 = [ - "byteorder", - "dbus-secret-service", "log", - "openssl", - "secret-service", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zeroize", ] [[package]] @@ -4658,7 +4907,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tracing", ] @@ -4686,6 +4935,77 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" +[[package]] +name = "launchdarkly-sdk-transport" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032d66802c461d7254ed1de887be6ca98ebbe126867d9a60c754baae81c4d10d" +dependencies = [ + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "log", + "no-proxy", + "tower 0.4.13", +] + +[[package]] +name = "launchdarkly-server-sdk" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22259877ec581fa56a9f0b982e583e16fa01b71ba6d3f786ef5ccfc6ca6f92ff" +dependencies = [ + "aws-lc-rs", + "bitflags 2.13.0", + "bytes", + "chrono", + "crossbeam-channel", + "data-encoding", + "eventsource-client", + "flate2", + "futures", + "http", + "launchdarkly-sdk-transport", + "launchdarkly-server-sdk-evaluation", + "log", + "lru 0.16.4", + "moka", + "parking_lot", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "launchdarkly-server-sdk-evaluation" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76560a130b2a7e7fdb9c56043168844cde483e08e7f0e4a5e178e3888edc4336" +dependencies = [ + "base16ct 0.2.0", + "chrono", + "itertools", + "lazy_static", + "log", + "maplit", + "regex", + "semver", + "serde", + "serde_json", + "serde_with", + "sha1 0.10.6", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -4705,25 +5025,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "cc", - "pkg-config", -] +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" -version = "0.8.9" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if 1.0.4", "windows-link 0.2.1", @@ -4746,9 +5056,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -4776,12 +5086,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4861,6 +5165,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + [[package]] name = "lru" version = "0.18.0" @@ -4917,6 +5227,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "matchers" version = "0.2.0" @@ -4971,12 +5287,12 @@ checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "mdns-sd" -version = "0.19.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18148fee27e99e76dbf6e137f27727113d31f766e578d1b93a93c3615fca7081" +checksum = "a63c9b854b5ad0812ac5969f8db92a59f28b45d0a5ae599b9a45b84c5c8a08e8" dependencies = [ "fastrand", - "flume 0.11.1", + "flume", "if-addrs", "log", "mio", @@ -4990,6 +5306,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memmem" version = "0.1.1" @@ -5007,8 +5332,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "hex", "mesh-llm-client", @@ -5017,8 +5342,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5028,17 +5353,17 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" [[package]] name = "mesh-llm-client" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.23.1", "bytes", "httparse", "iroh", @@ -5060,24 +5385,25 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "dirs", + "mesh-llm-native-runtime", "mesh-llm-types", "semver", "serde", "skippy-protocol", - "toml 0.9.12+spec-1.1.0", + "toml", "toml_edit", "url", ] [[package]] name = "mesh-llm-embedded-runtime" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5086,13 +5412,13 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "chrono", "clap", - "crossterm 0.28.1", + "crossterm", "libc", "ratatui", "serde", @@ -5103,8 +5429,8 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", "serde_json", @@ -5113,8 +5439,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", "serde_json", @@ -5122,8 +5448,8 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "mesh-llm-native-runtime", ] @@ -5159,19 +5485,19 @@ dependencies = [ [[package]] name = "mesh-llm-host-runtime" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "argon2", "async-trait", "axum", - "base64 0.22.1", + "base64 0.23.1", "bytes", - "chacha20poly1305", + "chacha20poly1305 0.11.0", "chrono", "clap", - "crossterm 0.28.1", + "crossterm", "crypto_box", "dirs", "ed25519-dalek", @@ -5219,17 +5545,20 @@ dependencies = [ "prost 0.14.3", "rand 0.10.1", "regex-lite", - "reqwest 0.12.28", - "rmcp", + "reqwest 0.13.4", + "rmcp 3.2.0", "rpassword", "rustls", - "schemars", + "same-file", + "schemars 1.2.1", "semver", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-coordinator", "skippy-ffi", + "skippy-model", + "skippy-package-format", "skippy-protocol", "skippy-runtime", "skippy-server", @@ -5241,7 +5570,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "toml 0.9.12+spec-1.1.0", + "toml", "tracing", "tracing-subscriber", "url", @@ -5254,12 +5583,13 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ + "apple-native-keyring-store", "argon2", - "base64 0.22.1", - "chacha20poly1305", + "base64 0.23.1", + "chacha20poly1305 0.11.0", "chrono", "crypto_box", "dirs", @@ -5269,15 +5599,15 @@ dependencies = [ "rand 0.10.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "thiserror 2.0.18", "zeroize", ] [[package]] name = "mesh-llm-log-store" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "chrono", "data-encoding", @@ -5286,7 +5616,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "thiserror 2.0.18", "uuid", "windows-sys 0.61.2", @@ -5294,19 +5624,20 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", + "hex", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "mesh-llm-node" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "mesh-llm-types", @@ -5319,16 +5650,16 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "async-trait", "prost 0.14.3", "prost-build 0.14.3", "protoc-bin-vendored", - "rmcp", - "schemars", + "rmcp 3.2.0", + "schemars 1.2.1", "serde", "serde_json", "tokio", @@ -5336,18 +5667,19 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "dirs", "flate2", "futures-util", + "hex", "mesh-llm-skills", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tar", "tempfile", "zip", @@ -5355,30 +5687,30 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "hex", "iroh", "prost 0.14.3", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "mesh-llm-release-footer" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "hex", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "mesh-llm-routing" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "blake3", "iroh", @@ -5388,8 +5720,8 @@ dependencies = [ [[package]] name = "mesh-llm-runtime-install" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "dirs", @@ -5399,10 +5731,10 @@ dependencies = [ "mesh-llm-build-info", "mesh-llm-hardware-profile", "mesh-llm-native-runtime", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-ffi", "tar", "tempfile", @@ -5411,23 +5743,23 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "mesh-llm-api-client", "mesh-llm-api-server", "mesh-llm-embedded-runtime", "mesh-llm-runtime-install", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", ] [[package]] name = "mesh-llm-skills" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "dirs", @@ -5437,8 +5769,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "chrono", @@ -5452,11 +5784,11 @@ dependencies = [ "mesh-llm-native-runtime", "mesh-llm-release-footer", "mesh-llm-runtime-install", - "reqwest 0.12.28", + "reqwest 0.13.4", "semver", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-runtime", "tracing", "zip", @@ -5464,28 +5796,28 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "hex", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "mesh-llm-ui" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" [[package]] name = "mesh-mixture-of-agents" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "async-trait", "mesh-llm-guardrails", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -5494,13 +5826,13 @@ dependencies = [ [[package]] name = "mesh-native-serving-plugin-api" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" [[package]] name = "mesh-native-serving-plugin-host" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "libloading", @@ -5531,7 +5863,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "indexmap", + "indexmap 2.14.0", "ipnet", "metrics", "metrics-util", @@ -5552,7 +5884,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "metrics", "ordered-float 5.3.0", "quanta", @@ -5646,8 +5978,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "async-trait", @@ -5657,13 +5989,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", + "hex", "libc", "mesh-llm-hf-hub", "model-artifact", @@ -5671,15 +6004,15 @@ dependencies = [ "rustls", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tokio", "tracing", ] [[package]] name = "model-package" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "bytes", @@ -5688,26 +6021,26 @@ dependencies = [ "mesh-llm-hf-hub", "model-hf", "model-ref", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tempfile", "tokio", ] [[package]] name = "model-ref" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "model-artifact", @@ -5844,23 +6177,6 @@ dependencies = [ "n0-future", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 3.7.0", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndarray" version = "0.17.2" @@ -6046,6 +6362,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no-proxy" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd3f71e44f009513a82ac2612c0c2da7e2924fad776931164317baa9695e150" +dependencies = [ + "cidr", +] + [[package]] name = "nom" version = "7.1.3" @@ -6129,9 +6454,9 @@ dependencies = [ "bech32 0.11.1", "bip39", "bitcoin_hashes 0.14.1", - "cbc", + "cbc 0.1.2", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "getrandom 0.2.17", "hex", "instant", @@ -6153,9 +6478,9 @@ dependencies = [ "bech32 0.12.0", "bip39", "bitcoin_hashes 1.2.0", - "cbc", + "cbc 0.1.2", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "faster-hex", "opaquerr", "rand 0.10.1", @@ -6198,7 +6523,7 @@ dependencies = [ "async-wsocket", "faster-hex", "futures", - "lru", + "lru 0.18.0", "negentropy", "nostr 0.45.1", "nostr-database", @@ -6226,7 +6551,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6517,8 +6842,8 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" [[package]] name = "openai-frontend" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "async-trait", "axum", @@ -6564,15 +6889,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.6.0+3.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.116" @@ -6581,7 +6897,6 @@ checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -6832,6 +7147,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -6943,7 +7268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset 0.5.7", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -6954,7 +7279,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -6967,6 +7292,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.11.3" @@ -7089,7 +7425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", - "indexmap", + "indexmap 2.14.0", "quick-xml 0.39.4", "serde", "time", @@ -7118,7 +7454,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -7130,7 +7466,17 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -7142,7 +7488,7 @@ dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -7295,7 +7641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", - "indexmap", + "indexmap 2.14.0", "nix 0.31.3", "tokio", "tracing", @@ -7826,7 +8172,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru", + "lru 0.18.0", "palette", "serde", "strum", @@ -7843,7 +8189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if 1.0.4", - "crossterm 0.29.0", + "crossterm", "instability", "ratatui-core", ] @@ -8081,21 +8427,16 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -8106,10 +8447,9 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -8156,7 +8496,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -8218,19 +8558,44 @@ checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "process-wrap", + "rmcp-macros 1.8.0", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5" +dependencies = [ + "async-trait", + "base64 0.23.1", "bytes", "chrono", "futures", "http", "http-body", "http-body-util", + "indexmap 2.14.0", "pastey", "pin-project-lite", "process-wrap", "rand 0.10.1", "reqwest 0.13.4", - "rmcp-macros", - "schemars", + "rmcp-macros 3.2.0", + "schemars 1.2.1", "serde", "serde_json", "sse-stream", @@ -8256,6 +8621,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rmcp-macros" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -8279,14 +8657,13 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.37.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", - "hashlink 0.10.0", "libsqlite3-sys", "smallvec", ] @@ -8360,19 +8737,6 @@ dependencies = [ "nom", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -8382,8 +8746,8 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] @@ -8411,7 +8775,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.7.0", + "security-framework", ] [[package]] @@ -8439,10 +8803,10 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.7.0", + "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8512,13 +8876,26 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" +[[package]] +name = "safetensors" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +dependencies = [ + "hashbrown 0.16.1", + "libc", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "salsa20" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -8539,6 +8916,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.1" @@ -8583,7 +8972,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "password-hash", + "password-hash 0.5.0", "pbkdf2", "salsa20", "sha2 0.10.9", @@ -8665,36 +9054,23 @@ dependencies = [ [[package]] name = "secret-service" -version = "4.0.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ - "aes", - "cbc", + "aes 0.9.3", + "cbc 0.2.1", "futures-util", - "generic-array", - "hkdf 0.12.4", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", "num", "once_cell", - "rand 0.8.6", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "zbus", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -8725,7 +9101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8869,13 +9245,46 @@ dependencies = [ "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.1", + "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 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -9036,6 +9445,9 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "simd-adler32" @@ -9103,8 +9515,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "blake3", @@ -9113,29 +9525,53 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" + +[[package]] +name = "skippy-model" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" +dependencies = [ + "anyhow", + "clap", + "memmap2", + "safetensors", + "serde", + "serde_json", + "skippy-package-format", +] + +[[package]] +name = "skippy-package-format" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" +dependencies = [ + "serde", + "serde_json", + "sha2 0.11.0", +] [[package]] name = "skippy-protocol" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "prost 0.14.3", "prost-build 0.14.3", @@ -9146,22 +9582,25 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", + "hex", "libc", + "model-ref", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-ffi", + "skippy-model", "tokio", ] [[package]] name = "skippy-scheduler" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "skippy-runtime", "thiserror 2.0.18", @@ -9169,14 +9608,14 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "ahash", "anyhow", "async-trait", "axum", - "base64 0.22.1", + "base64 0.23.1", "blake3", "clap", "futures-util", @@ -9188,7 +9627,7 @@ dependencies = [ "opentelemetry-proto", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-cache", "skippy-metrics", "skippy-protocol", @@ -9199,21 +9638,22 @@ dependencies = [ "socket2", "tokio", "tonic", + "tracing", "uuid", ] [[package]] name = "skippy-tokenizer" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", ] [[package]] name = "skippy-topology" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", "serde_json", @@ -9236,13 +9676,13 @@ dependencies = [ [[package]] name = "socket-pktinfo" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" dependencies = [ "libc", "socket2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9252,7 +9692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9334,7 +9774,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ - "sqlx-core", + "sqlx-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "sqlx-macros", "sqlx-mysql", "sqlx-postgres", @@ -9360,8 +9800,8 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.16.1", - "hashlink 0.11.0", - "indexmap", + "hashlink", + "indexmap 2.14.0", "log", "memchr", "percent-encoding", @@ -9379,6 +9819,39 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "git+https://github.com/launchbadge/sqlx?rev=94aafe3a68884d923b0798a767c8d7f6cfda89d2#94aafe3a68884d923b0798a767c8d7f6cfda89d2" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if 1.0.4", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + [[package]] name = "sqlx-macros" version = "0.9.0" @@ -9387,7 +9860,7 @@ checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", - "sqlx-core", + "sqlx-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "sqlx-macros-core", "syn 2.0.117", ] @@ -9408,7 +9881,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core", + "sqlx-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", @@ -9440,7 +9913,7 @@ dependencies = [ "serde", "sha1 0.11.0", "sha2 0.11.0", - "sqlx-core", + "sqlx-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "thiserror 2.0.18", "tracing", "uuid", @@ -9464,7 +9937,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf 0.13.0", + "hkdf", "hmac 0.13.0", "itoa", "log", @@ -9475,7 +9948,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "smallvec", - "sqlx-core", + "sqlx-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "stringprep", "thiserror 2.0.18", "tracing", @@ -9486,12 +9959,11 @@ dependencies = [ [[package]] name = "sqlx-sqlite" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +source = "git+https://github.com/launchbadge/sqlx?rev=94aafe3a68884d923b0798a767c8d7f6cfda89d2#94aafe3a68884d923b0798a767c8d7f6cfda89d2" dependencies = [ "atoi", "chrono", - "flume 0.12.0", + "flume", "form_urlencoded", "futures-channel", "futures-core", @@ -9502,7 +9974,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sqlx-core", + "sqlx-core 0.9.0 (git+https://github.com/launchbadge/sqlx?rev=94aafe3a68884d923b0798a767c8d7f6cfda89d2)", "thiserror 2.0.18", "tracing", "url", @@ -9511,9 +9983,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" dependencies = [ "bytes", "futures-util", @@ -9889,8 +10361,8 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", - "windows-sys 0.52.0", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -9901,9 +10373,9 @@ checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ "bitflags 2.13.0", "parking_lot", - "rustix 1.1.4", + "rustix", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10156,16 +10628,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-retry" version = "0.3.1" @@ -10305,45 +10767,21 @@ dependencies = [ "syn 2.0.117", ] -[[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", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", "toml_writer", "winnow 1.0.3", ] -[[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" @@ -10359,8 +10797,8 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "indexmap 2.14.0", + "toml_datetime", "toml_parser", "toml_writer", "winnow 1.0.3", @@ -10405,7 +10843,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-stream", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -10433,6 +10871,17 @@ dependencies = [ "tonic", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -10441,7 +10890,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -10475,7 +10924,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -10647,6 +11096,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.0" @@ -10673,7 +11128,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10785,6 +11240,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "universal-time" version = "0.3.1" @@ -11226,7 +11691,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11360,6 +11825,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -11445,15 +11923,6 @@ 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" @@ -11765,17 +12234,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", -] - -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", + "rustix", ] [[package]] @@ -11975,60 +12434,85 @@ dependencies = [ [[package]] name = "zbus" -version = "4.4.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +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-sink", - "futures-util", + "futures-lite", "hex", - "nix 0.29.0", + "libc", "ordered-stream", - "rand 0.8.6", + "rustix", "serde", "serde_repr", - "sha1 0.10.6", - "static_assertions", "tracing", "uds_windows", - "windows-sys 0.52.0", - "xdg-home", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", ] [[package]] -name = "zbus_macros" -version = "4.4.0" +name = "zbus-secret-service-keyring-store" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +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", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", + "zbus_names", + "zvariant", "zvariant_utils", ] [[package]] name = "zbus_names" -version = "3.0.0" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "static_assertions", + "winnow 1.0.3", "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.48" @@ -12125,21 +12609,24 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", - "indexmap", + "indexmap 2.14.0", "memchr", - "thiserror 2.0.18", + "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.21" @@ -12175,37 +12662,41 @@ dependencies = [ [[package]] name = "zvariant" -version = "4.2.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "static_assertions", + "winnow 1.0.3", + "zcheapstr", "zvariant_derive", + "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "4.2.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "2.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "serde", + "syn 3.0.3", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 3e47708e37..d27c63d234 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/buzz-ws-client", "crates/buzz-admin", "crates/buzz-deletion", + "crates/buzz-feature-flags", "crates/buzz-workflow", "crates/buzz-media", "crates/buzz-cli", @@ -27,6 +28,7 @@ members = [ "crates/git-sign-nostr", "crates/buzz-pair-relay", "crates/buzz-relay-mesh", + "crates/buzz-mesh-smoke", "crates/buzz-dev-mcp", "crates/buzz-voice", "crates/buzz-backend-kubernetes", @@ -141,6 +143,7 @@ buzz-core = { path = "crates/buzz-core" } buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } buzz-deletion = { path = "crates/buzz-deletion" } +buzz-feature-flags = { path = "crates/buzz-feature-flags" } buzz-auth = { path = "crates/buzz-auth" } buzz-pubsub = { path = "crates/buzz-pubsub" } buzz-search = { path = "crates/buzz-search" } @@ -187,3 +190,8 @@ strip = true # allowlist for the auth token). Revert to crates.io once #449 lands upstream. [patch.crates-io] aws-creds = { git = "https://github.com/tlongwell-block/rust-s3", rev = "c9fce3620dd434c1f810101d672cf384268dbb0f" } +# MeshLLM 0.76 uses rusqlite 0.40 / libsqlite3-sys 0.38. SQLx 0.9.0's +# optional SQLite driver still caps libsqlite3-sys below 0.38, which Cargo +# resolves even though Buzz only enables PostgreSQL. Pin the merged upstream +# compatibility fix until it is released: https://github.com/transact-rs/sqlx/pull/4402 +sqlx-sqlite = { git = "https://github.com/launchbadge/sqlx", rev = "94aafe3a68884d923b0798a767c8d7f6cfda89d2" } diff --git a/Justfile b/Justfile index 4174b16609..385809fd05 100644 --- a/Justfile +++ b/Justfile @@ -414,6 +414,10 @@ test-unit: # because nothing in CI runs `cargo test --workspace` — workspace # membership alone buys clippy/check, not a single executed test. cargo nextest run -p buzz-backend-kubernetes + # Feature-flag crate coverage: run once with LaunchDarkly enabled. + # This includes all default tests plus the cfg(feature="launchdarkly") + # tests, avoiding duplicate default-only execution in the unit lane. + cargo nextest run -p buzz-feature-flags --features launchdarkly # buzz-agent: two infra-free concerns run together by executing the # whole crate (lib + integration tests), because nothing in CI runs # `cargo test --workspace`, so without this stanza neither the crate's @@ -440,18 +444,11 @@ test-unit: # and a red one could ship green (exactly how a broken admin test slipped # past every gate once). Scoped to api::admin, not the whole buzz-relay # --lib, because api::media has non-ignored tests that require Postgres. - # Two api::admin tests are excluded: both exercise a read-route DB - # fallthrough and pass without a database only by waiting out the sqlx - # acquire timeout (~30s each), so they do not belong in the infra-free - # unit job. nip98_mode_unrostered_signer_does_not_consume_a_replay_slot - # asserts a unique replay-guard invariant, so it is wired into the - # Postgres-backed Backend Integration job (see ci.yml "Admin API - # unrostered-signer replay invariant"). disabled_mode_allows_ - # unauthenticated_requests_on_the_admin_host has no unique invariant: - # disabled-mode unauthenticated success is covered by - # disabled_mode_regression_pin_unauthenticated_request_is_served on the - # DB-free /probe route, and its Host/Origin gating is covered here by - # disabled_mode_still_requires_the_correct_host / _a_matching_origin. + # DB-backed api::admin tests are #[ignore]d and run in the PostgreSQL + # lane; the non-ignored ones reject before touching the database. Any + # new non-ignored test here must stay DB-free: without a database, a + # DB fallthrough only "passes" by waiting out the ~30s sqlx acquire + # timeout. # The second clause adds the relay's pure authorization-decision tests: # the NIP-29 channel membership grid (handlers::channel_authz), the # moderation capability grid (handlers::moderation_authz), and the pure @@ -466,7 +463,7 @@ test-unit: # the ~30s sqlx acquire timeout, so they do not belong in the infra-free # unit job either. cargo nextest run -p buzz-relay --lib \ - -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/)' + -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/)' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. @@ -516,23 +513,23 @@ mesh-dev-fresh: mesh-e2e-hardware: #!/usr/bin/env bash set -euo pipefail - cargo run -p buzz-relay --example mesh_serve_client_smoke + cargo run -p buzz-mesh-smoke --example mesh_serve_client_smoke # Three isolated node processes: trusted member joins and infers; stranger is rejected. # Uses temp homes and explicit mesh owner keystores. Never reads the Buzz Keychain. mesh-e2e-admission: #!/usr/bin/env bash set -euo pipefail - cargo run -p buzz-relay --example mesh_admission_smoke + cargo run -p buzz-mesh-smoke --example mesh_admission_smoke # Full hardware confidence suite: routing, owner admission, and real agent inference. mesh-e2e-confidence: #!/usr/bin/env bash set -euo pipefail cargo build --release -p buzz-agent -p buzz-dev-mcp - cargo run -p buzz-relay --example mesh_serve_client_smoke - cargo run -p buzz-relay --example mesh_admission_smoke - cargo run -p buzz-relay --example mesh_agent_e2e + cargo run -p buzz-mesh-smoke --example mesh_serve_client_smoke + cargo run -p buzz-mesh-smoke --example mesh_admission_smoke + cargo run -p buzz-mesh-smoke --example mesh_agent_e2e # Take desktop screenshots using the mock bridge desktop-screenshot *ARGS: @@ -734,7 +731,7 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done cd {{desktop_dir}} - export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" + export BUZZ_RELAY_URL="wss://buzz.test.blockstaging.build" source ../scripts/instance-env.sh # Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking # agent workers. Reap this instance's agents on exit as a backstop. diff --git a/VISION.md b/VISION.md index 900e5a9475..96491c7c65 100644 --- a/VISION.md +++ b/VISION.md @@ -74,6 +74,8 @@ Buzz extends the standard Nostr event format with custom kind numbers for enterp New message type? New kind integer. Zero breaking changes. +**The conversation is Nostr; Buzz adds accessories around it.** Messages, threads, reactions, and the rest of the conversation are signed Nostr events, and Nostr reads must cover all of that state, so a standards-leaning Buzz client stays possible. Around it, the relay serves accessories that don't fit that shape: Blossom media, git smart HTTP, workflow webhooks, derived unread counts, and namespaced `/buzz/v1/...` endpoints for backend-for-frontend reads (thread catch-up, sync) over the same authoritative store. We start with this rule and refine it as more cases show up. + --- ## Architecture diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index 07955ebbeb..244bae144e 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -31,7 +31,7 @@ zeroize = { workspace = true } nostr = { workspace = true } # Async runtime -tokio = { workspace = true } +tokio = { workspace = true, features = ["io-std"] } # WebSocket tokio-tungstenite = { workspace = true } @@ -80,8 +80,9 @@ evalexpr = { workspace = true } # Process-group kill (safe wrapper around killpg) — Unix-only; kill_process_group # has a #[cfg(not(unix))] fallback in acp.rs. [target.'cfg(unix)'.dependencies] -nix = { version = "0.31", default-features = false, features = ["signal"] } +nix = { version = "0.31", default-features = false, features = ["signal", "user"] } [dev-dependencies] +axum = { workspace = true } tokio = { workspace = true, features = ["test-util"] } httparse = "1" diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 76f73dbf4e..7e7b3e5ff0 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -1,5 +1,10 @@ # buzz-acp +For one prepared local task, use **`buzz-acp run --task `**. See +[Local task runner and version-1 task contract](TASKS.md). With no command, +`buzz-acp` remains the conversational service. + + ACP harness that connects AI agents to Buzz. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using the Buzz CLI. ``` diff --git a/crates/buzz-acp/TASKS.md b/crates/buzz-acp/TASKS.md new file mode 100644 index 0000000000..4ef574d650 --- /dev/null +++ b/crates/buzz-acp/TASKS.md @@ -0,0 +1,153 @@ +# Local task runner + +`buzz-acp run` runs one prepared task in one fresh agent process and ACP session, +then exits. Scripts and automation can use it without starting a conversational +`buzz-acp` service or sending a chat message. + +```sh +buzz-acp run --task ./task.json +cat task.json | buzz-acp run --task - +``` + +Put command options after `run`. Use the normal trusted launch settings for +identity, relay, adapter, model, effort, permissions, prompts, MCP tools, and +memory. `buzz-acp run --help` lists them. The working directory is the launch +working directory, not the task file's parent. The task cannot change it. + +## Task document, version 1 + +Supply exactly one UTF-8 JSON object, at most 1 MiB including whitespace: + +```json +{ + "version": 1, + "taskId": "example-001", + "agentPubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "prompt": "Inspect the current workspace. Report your findings using the task's specified destination.", + "maxDurationMs": 120000 +} +``` + +The example public key is illustrative; use the public key of the configured +agent. Never put a private key in a task document. + +| Field | Contract | +| --- | --- | +| `version` | Integer `1`. Other versions fail before agent launch. | +| `taskId` | Nonblank string, 1–256 UTF-8 bytes, no control characters. Correlation only, not a claim or deduplication key. | +| `agentPubkey` | Exactly 64 lowercase hexadecimal characters. Must equal the configured agent's public key. | +| `prompt` | Nonblank prepared task string. Include relevant context, original evidence, and reporting instructions here. | +| `maxDurationMs` | Positive unsigned 64-bit integer. The shorter of this limit and the trusted `--max-turn-duration` cap wins. | + +All fields are required. Unknown fields, duplicate fields, wrong types, trailing +JSON documents, and malformed JSON are rejected. File/stdin input has a ten-second +read limit. Close stdin after the document. The input limit is separate from the +execution deadline. + +Launch configuration is the authority for credentials, executable, tools, +permissions, and relay. A document is **executable task input**, not a sandboxed +message: only feed tasks that are trusted to use that agent's capabilities. The +identity check prevents accidental execution by another agent; it does not +verify the document's author. File/stdin input does not need a signature. + +## Session behavior + +- Load normal base, persona, and team instructions. Add the task-session context + instead of a conversational session description. Modern adapters receive + standing instructions through session setup; legacy adapters receive them with + the first prompt. `--no-base-prompt` and custom base files still work. +- Load enabled core memory for the resolved owner and identity before session + creation. `--no-memory` skips this. No owner means no memory namespace. The + normal three-second fetch bound applies. Confirmed absence supplies the normal + onboarding hint; a failed read supplies no hint, not a false empty-memory claim. +- Apply model, supported effort and permission settings through the existing + session setup. Unsupported settings retain the existing adapter fallback + behavior. Pass configured identity and relay to the child and MCP tools; keep + the inherited authorization and other launch environment. The shared runtime + prepares Git identity, scoped credentials, and signing helpers for both task + and conversational sessions. Temporary key material lives until adapter + cleanup is complete. +- Submit one prepared task, allowing multiple model/tool exchanges. There is no + initial-message turn, heartbeat turn, conversation history, automatic channel + context, or second task. Context must be supplied or fetched with tools. +- Do not start ordinary relay subscriptions, a standing pool, presence, typing, + a setup listener, observer publication, or an isolated-turn socket. Inherited + service-only options cannot enable these features. Shared option parsing still + validates launch settings. +- Do not discover, borrow, stop, or reconfigure an existing host. Memory, files, + credentials, and external side effects may be shared with concurrent sessions. + Session isolation is not a security sandbox or a concurrency lock. +- Do not automatically publish final text. Use task reporting instructions and + tools. Do not retry the task after failure, timeout, or uncertain execution. + +Launch configuration preparation (including prompt-file reads) has a separate +ten-second bound. A blocked configuration read reports `configuration_timeout` +with exit 2; SIGINT/SIGTERM during that read reports `cancelled` with exit +130/143 and a null `taskId`. No adapter is started during this phase. + +The execution deadline covers adapter startup, memory loading, session setup, +and the task turn. SIGINT/SIGTERM cancel the invocation. If a prompt is active, +allow up to five seconds for cooperative cancellation, then kill and reap the +child, with a further five-second reap bound. Startup and setup cancellation do +not wait for a session to become ready. On Unix, cleanup kills the process group, +including ordinary descendants. Processes that deliberately escape that group +are outside this guarantee. On non-Unix platforms cleanup uses the existing +ACP direct-child cleanup; descendant containment is not promised. + +## Terminal output + +For each attempted invocation, stdout contains one JSON line (except help/version +output). Diagnostics and child stderr stay on stderr. No prompt, private key, +agent final text, or raw adapter error is included in the terminal record. + +```json +{"version":1,"taskId":"example-001","status":"completed","stopReason":"end_turn"} +``` + +`taskId` is null until the document is validated. `status` is one of `completed`, +`failed`, `invalid`, `timed_out`, or `cancelled`. `stopReason` is present only for +normal completion: `end_turn`, `max_tokens`, `max_turn_requests`, or `refusal`. +Failures include a stable, sanitized `error` code when available, such as +`agent_identity_mismatch`, `invalid_task_json`, or `agent_spawn_failed`. + +| Exit | Meaning | +| --- | --- | +| `0` | Turn returned normally. Inspect `stopReason`; this is not proof of task success. | +| `1` | Startup/execution failure, adapter cancellation, or terminal-output failure. | +| `2` | Invalid arguments, launch configuration, or task input/read. | +| `124` | Execution deadline exceeded. | +| `130` | SIGINT; bounded cleanup ran. | +| `143` | SIGTERM; bounded cleanup ran. | + +A killed host or broken stdout can leave no terminal record. A record is not a +persistent result ledger. Launching the same task twice can execute it twice. + +## Future work + +URL input and task-server retrieval are **not implemented**. URL-shaped sources +are rejected, not fetched. A future design can GET a supplied URL and validate +the same task document, with explicit authentication, size/time bounds, and +redirect rules. This project adds no HTTP task endpoint or job queue. + +## Shared runtime ownership + +`runtime::AgentRuntime` prepares shared capabilities and owns their lifetime. +Both entry points obtain adapter configuration and prompt context from it; +add new shared capabilities there rather than in either entry point. Session +creation, tool configuration, model/effort/permissions, and legacy prompt +framing use the existing pool code. Mode-specific code owns task input and +terminal output or conversational intake and scheduling, not agent equipment. + +## Build and verification + +```sh +. ./bin/activate-hermit +cargo build --release -p buzz-acp +cargo test -p buzz-acp +``` + +`tests/run_task.rs` invokes the actual binary with a deterministic ACP peer. It +checks stdin/file parity, fresh process/session setup, legacy and modern standing +instructions, tools, identity, model/effort/permissions, memory reads and opt-out, +invalid input, clean JSON, deadlines, signals, and Unix descendant cleanup. +These checks do not require a model provider or production relay. diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 82444152e6..eea500f669 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -459,6 +459,18 @@ impl AcpClient { args: &[String], extra_env: &[(String, String)], has_generated_codex_config: bool, + ) -> Result { + Self::spawn_with_env(command, args, extra_env, has_generated_codex_config, &[]).await + } + + /// Spawn with authoritative launch environment overrides. Unlike persona + /// defaults, these values take precedence over the inherited environment. + pub(crate) async fn spawn_with_env( + command: &str, + args: &[String], + extra_env: &[(String, String)], + has_generated_codex_config: bool, + launch_env: &[(String, String)], ) -> Result { use std::process::Stdio; @@ -572,6 +584,7 @@ impl AcpClient { "codex" | "codex-acp" => Some(StandardAdapterKind::Codex), _ => None, }; + cmd.envs(launch_env.iter().cloned()); let mut child = cmd.spawn()?; let stdin = child diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d3e0edc640..f06ffec564 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -240,7 +240,8 @@ pub struct AuthenticateArgs { #[derive(Debug, Parser)] #[command( name = "buzz-acp", - about = "ACP harness that bridges Buzz events to AI agents" + about = "ACP harness that bridges Buzz events to AI agents", + after_help = "Commands: run (one local task), models, auth-methods, authenticate.\nUse buzz-acp --help. With no command, start the conversational service." )] pub struct CliArgs { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")] diff --git a/crates/buzz-acp/src/git.rs b/crates/buzz-acp/src/git.rs index 081a18714b..b7cef0e640 100644 --- a/crates/buzz-acp/src/git.rs +++ b/crates/buzz-acp/src/git.rs @@ -11,6 +11,18 @@ pub(crate) struct GitEnvironment { } impl GitEnvironment { + pub(crate) fn for_config(config: &mut crate::config::Config) -> anyhow::Result { + let environment = + Self::install(&config.keys, &config.relay_url, &std::env::current_exe()?)?; + config + .persona_env_vars + .retain(|(name, _)| !is_managed_env(name)); + config + .persona_env_vars + .extend(environment.env.iter().cloned()); + Ok(environment) + } + pub(crate) fn install( keys: &nostr::Keys, relay_url: &str, diff --git a/crates/buzz-acp/src/isolated_execution.rs b/crates/buzz-acp/src/isolated_execution.rs new file mode 100644 index 0000000000..a31e3f259e --- /dev/null +++ b/crates/buzz-acp/src/isolated_execution.rs @@ -0,0 +1,95 @@ +//! One fresh process/session for a local task, with bounded cleanup. +use crate::{acp, pool, AcpClient, OwnedAgent, PoolStartup, PromptContext, SessionState}; +use std::time::Duration; +use tokio::sync::oneshot; + +pub(crate) enum Outcome { + Completed(String), + Cancelled, + Deadline, + Failed(&'static str), +} + +pub(crate) async fn execute( + startup: &PoolStartup, + ctx: &PromptContext, + prompt: &str, + duration: Duration, + mut cancel: oneshot::Receiver<()>, +) -> Outcome { + if !matches!(cancel.try_recv(), Err(oneshot::error::TryRecvError::Empty)) { + return Outcome::Cancelled; + } + let deadline = tokio::time::Instant::now() + duration; + let mut acp = match AcpClient::spawn( + &startup.command, + &startup.args, + &startup.extra_env, + startup.has_generated_codex_config, + ) + .await + { + Ok(acp) => acp, + Err(_) => return Outcome::Failed("agent_spawn_failed"), + }; + let initialized = tokio::select! { + biased; + _ = &mut cancel => Err(Outcome::Cancelled), + _ = tokio::time::sleep_until(deadline) => Err(Outcome::Deadline), + result = acp.initialize() => result.map_err(|_| Outcome::Failed("agent_initialize_failed")), + }; + let init = match initialized { + Ok(init) => init, + Err(outcome) => { + acp.shutdown().await; + return outcome; + } + }; + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: startup.model.clone(), + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: startup.effort_level.clone(), + agent_name: crate::normalized_agent_name(&init), + goose_system_prompt_supported: None, + protocol_version: init["protocolVersion"].as_u64().unwrap_or(1) as u32, + }; + let mut session = None; + let outcome = tokio::select! { + biased; + _ = &mut cancel => Outcome::Cancelled, + _ = tokio::time::sleep_until(deadline) => Outcome::Deadline, + result = pool::run_isolated_prompt(&mut agent, ctx, prompt, duration, &mut session) => { + match result { + Ok(acp::StopReason::Cancelled) => Outcome::Cancelled, + Ok(acp::StopReason::EndTurn) => Outcome::Completed("end_turn".into()), + Ok(acp::StopReason::MaxTokens) => Outcome::Completed("max_tokens".into()), + Ok(acp::StopReason::MaxTurnRequests) => Outcome::Completed("max_turn_requests".into()), + Ok(acp::StopReason::Refusal) => Outcome::Completed("refusal".into()), + Err(acp::AcpError::HardTimeout { .. }) => Outcome::Deadline, + Err(acp::AcpError::IdleTimeout(_)) => Outcome::Failed("agent_idle_timeout"), + Err(_) => Outcome::Failed("agent_execution_failed"), + } + } + }; + if matches!(outcome, Outcome::Cancelled | Outcome::Deadline) { + if let Some(session) = session { + // Cancellation is best effort; the terminal status remains the cause + // that won above, even if the adapter returns end_turn during drain. + let _ = tokio::time::timeout( + Duration::from_secs(5), + agent + .acp + .cancel_with_cleanup_grace(&session, Duration::from_secs(5)), + ) + .await; + } + } + agent.acp.shutdown().await; + outcome +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index cf723bcfd5..53b8604867 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -8,6 +8,7 @@ mod acp; mod config; mod engram_fetch; mod filter; +mod isolated_execution; mod observer; mod pool; mod pool_lifecycle; @@ -15,6 +16,9 @@ mod prompt_framing; mod prompt_project; mod queue; mod relay; +mod run_task; +mod runtime; +use runtime::{AgentRuntime, PoolStartup, SessionMode}; mod scope; mod setup_mode; mod usage; @@ -2459,6 +2463,14 @@ pub fn run() -> Result<()> { _ => {} } config::propagate_legacy_env_vars(); + if is_subcommand("run") { + let runtime = tokio::runtime::Runtime::new()?; + let code = runtime.block_on(run_task::run()); + // stdin/file reads can leave a blocking worker pending (for example an + // open pipe). Bound runtime shutdown; process exit retires those workers. + runtime.shutdown_timeout(Duration::from_millis(100)); + std::process::exit(code); + } tokio_main() } @@ -2500,7 +2512,10 @@ async fn tokio_main() -> Result<()> { return run_authenticate(args).await; } + // Stdout is the ACP transport when buzz-acp is launched as an agent command. + // Keep every harness diagnostic on stderr so logging can never corrupt NDJSON. tracing_subscriber::fmt() + .with_writer(std::io::stderr) .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("buzz_acp=info")), ) @@ -2558,19 +2573,13 @@ async fn tokio_main() -> Result<()> { } async fn run_harness( - mut config: Config, + config: Config, shutdown_tx: watch::Sender<()>, mut shutdown_rx: watch::Receiver<()>, startup_ready: tokio::sync::oneshot::Sender<()>, ) -> Result<()> { - let git_environment = - git::GitEnvironment::install(&config.keys, &config.relay_url, &std::env::current_exe()?)?; - config - .persona_env_vars - .retain(|(name, _)| !git::is_managed_env(name)); - config - .persona_env_vars - .extend(git_environment.env.iter().cloned()); + let runtime = AgentRuntime::prepare(config)?; + let config = runtime.config(); tracing::info!("buzz-acp starting: {}", config.summary()); @@ -2595,7 +2604,7 @@ async fn run_harness( let mut pool = if config.lazy_pool { AgentPool::from_slots((0..config.agents).map(|_| None).collect()) } else { - initialize_agent_pool(&PoolStartup::from_config(&config, observer.clone()), None).await? + initialize_agent_pool(&runtime.startup(observer.clone()), None).await? }; let mut pool_ready = !config.lazy_pool; let mut pool_lifecycle: PoolLifecycle = PoolLifecycle::listening(); @@ -2661,7 +2670,7 @@ async fn run_harness( let presence_keys = config.keys.clone(); // Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag. - let startup_owner: Option = resolve_agent_owner(&config); + let startup_owner: Option = resolve_agent_owner(config); if let Some(ref owner) = startup_owner { tracing::info!("agent owner: {owner}"); } else { @@ -2769,7 +2778,7 @@ async fn run_harness( } }; - let channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules); + let channel_filters = config::resolve_channel_filters(config, &channel_ids, &rules); if channel_filters.is_empty() { tracing::warn!("no channel subscriptions resolved — agent will sit idle"); } @@ -2822,47 +2831,11 @@ async fn run_harness( ); } - let base_prompt_content = config.base_prompt_content.take(); - let cwd = current_working_directory()?; - let ctx = Arc::new(PromptContext { - mcp_servers: build_mcp_servers(&config), - initial_message: config.initial_message.clone(), - idle_timeout: Duration::from_secs(config.idle_timeout_secs), - max_turn_duration: Duration::from_secs(config.max_turn_duration_secs), - turn_liveness_interval: Duration::from_secs(config.turn_liveness_secs), - dedup_mode: config.dedup_mode, - system_prompt: config.system_prompt.clone(), - session_title: config.session_title.clone(), - team_instructions: config.team_instructions.clone(), - base_prompt: if config.no_base_prompt { - None - } else { - // Build standing context once under the configured policy, before - // any session/new. Both modern ACP and legacy first-turn framing - // consume this same assembled base (including custom base files). - Some( - config.session_policy.append_session_model( - base_prompt_content - .as_deref() - .unwrap_or(include_str!("base_prompt.md")), - ), - ) - }, - heartbeat_prompt: config.heartbeat_prompt.clone(), - cwd, - rest_client: relay.rest_client(), - channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), - context_message_limit: config.context_message_limit, - max_turns_per_session: config.max_turns_per_session, - permission_mode: config.permission_mode, - agent_keys: config.keys.clone(), - agent_owner_pubkey: startup_owner - .as_deref() - .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), - memory_enabled: config.memory_enabled, - harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), - relay_url: config.relay_url.clone(), - }); + let ctx = Arc::new(runtime.prompt_context( + relay.rest_client(), + channel_info_map, + SessionMode::Conversation, + )?); if !config.memory_enabled { tracing::info!( @@ -3046,7 +3019,7 @@ async fn run_harness( "waking", None, ); - let startup = PoolStartup::from_config(&config, observer.clone()); + let startup = runtime.startup(observer.clone()); let wake_tx = wake_tx.clone(); let wake_shutdown = shutdown_rx.clone(); wake_tasks.spawn(async move { @@ -3327,7 +3300,7 @@ async fn run_harness( if subscribed_channel_ids.contains(&ch) { tracing::debug!(channel_id = %ch, "membership notification: channel already subscribed"); - } else if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { + } else if let Some(filter) = config::resolve_dynamic_channel_filter(config, ch, &rules) { tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel"); if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { tracing::warn!("failed to subscribe to new channel {ch}: {e}"); @@ -3775,7 +3748,7 @@ async fn run_harness( if handle_prompt_result( &mut pool, &mut queue, - &config, + config, *result, &mut heartbeat_in_flight, &removed_channels, @@ -3791,7 +3764,7 @@ async fn run_harness( if drain_ready_join_results( &mut pool, &mut queue, - &config, + config, &mut heartbeat_in_flight, &removed_channels, &mut typing_channels, @@ -3818,7 +3791,7 @@ async fn run_harness( recover_panicked_agent( &mut pool, &mut queue, - &config, + config, join_error, &mut heartbeat_in_flight, &removed_channels, @@ -5488,32 +5461,6 @@ async fn shutdown_agent_pool(pool: &mut AgentPool) { } } -struct PoolStartup { - agents: u32, - command: String, - args: Vec, - extra_env: Vec<(String, String)>, - has_generated_codex_config: bool, - model: Option, - effort_level: Option, - observer: Option, -} - -impl PoolStartup { - fn from_config(config: &Config, observer: Option) -> Self { - Self { - agents: config.agents, - command: config.agent_command.clone(), - args: config.agent_args.clone(), - extra_env: config.persona_env_vars.clone(), - has_generated_codex_config: config.has_generated_codex_config, - model: config.model.clone(), - effort_level: config.effort_level.clone(), - observer, - } - } -} - async fn initialize_agent_pool( startup: &PoolStartup, mut shutdown: Option>, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index c188633bef..15df4f63f8 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1754,6 +1754,67 @@ async fn create_session_and_apply_model( Ok(resp.session_id) } +/// Run a prepared task with the normal session setup and standing instructions. +/// The execution owner bounds startup, memory loading, and the turn together. +pub(crate) async fn run_isolated_prompt( + agent: &mut OwnedAgent, + ctx: &PromptContext, + prompt: &str, + max_duration: Duration, + active_session: &mut Option, +) -> Result { + let core = if ctx.memory_enabled { + if let Some(owner) = &ctx.agent_owner_pubkey { + tokio::time::timeout( + Duration::from_secs(3), + crate::engram_fetch::build_core_section(&ctx.rest_client, &ctx.agent_keys, owner), + ) + .await + .unwrap_or_else(|_| { + tracing::warn!("core fetch timed out — emitting no section"); + None + }) + } else { + None + } + } else { + None + }; + let session_id = create_session_and_apply_model( + agent, + ctx, + core.as_deref(), + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + scope: None, + channel_type: None, + }, + ) + .await?; + *active_session = Some(session_id.clone()); + let prompt = prepend_standing_for_legacy( + if agent.has_system_prompt_support() { + 2 + } else { + 1 + }, + &crate::queue::StandingContext { + base_prompt: ctx.base_prompt.as_deref(), + system_prompt: ctx.system_prompt.as_deref(), + team_instructions: ctx.team_instructions.as_deref(), + agent_core: core.as_deref(), + ..Default::default() + }, + prompt, + ); + agent + .acp + .session_prompt_with_idle_timeout(&session_id, &prompt, ctx.idle_timeout, max_duration) + .await +} + fn mcp_servers_with_git_origin( servers: &[McpServer], channel_id: Option, diff --git a/crates/buzz-acp/src/run_task.rs b/crates/buzz-acp/src/run_task.rs new file mode 100644 index 0000000000..a12c272622 --- /dev/null +++ b/crates/buzz-acp/src/run_task.rs @@ -0,0 +1,285 @@ +//! Local version-1 task input and terminal CLI contract. No service startup. +use crate::{ + config::{CliArgs, Config}, + isolated_execution::{self, Outcome}, + runtime::{AgentRuntime, SessionMode}, +}; +use clap::Parser; +use serde::{Deserialize, Serialize}; +use std::{io::Write, time::Duration}; +use tokio::io::AsyncReadExt; + +const MAX_TASK_BYTES: u64 = 1024 * 1024; +const INPUT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Parser)] +#[command( + name = "buzz-acp run", + about = "Run one local task in a fresh agent session, then exit", + after_help = "--task accepts a local file or - (stdin). URLs are not supported.\nExisting launch options are accepted; service-only options do not start service features.\nstdout: one version-1 JSON terminal record; stderr: diagnostics.\nExit: 0 completed, 1 failed/cancelled, 2 invalid input/config, 124 deadline, 130 SIGINT, 143 SIGTERM." +)] +struct RunArgs { + /// One versioned JSON task from a local file, or - for stdin. + #[arg(long, value_name = "PATH|-")] + task: String, + #[command(flatten)] + launch: CliArgs, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Task { + version: u8, + task_id: String, + agent_pubkey: String, + prompt: String, + max_duration_ms: u64, +} + +impl Task { + fn parse(bytes: &[u8]) -> Result { + let task: Self = serde_json::from_slice(bytes).map_err(|_| "invalid_task_json")?; + if task.version != 1 { + return Err("unsupported_task_version"); + } + if task.task_id.is_empty() + || task.task_id.len() > 256 + || task.task_id.chars().any(char::is_control) + || task.task_id.trim().is_empty() + || task.prompt.trim().is_empty() + || task.max_duration_ms == 0 + || task.agent_pubkey.len() != 64 + || !task + .agent_pubkey + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err("invalid_task_fields"); + } + Ok(task) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Terminal { + version: u8, + task_id: Option, + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + stop_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'static str>, +} +impl Terminal { + fn new(status: &'static str, error: Option<&'static str>) -> Self { + Self { + version: 1, + task_id: None, + status, + error, + stop_reason: None, + } + } +} + +async fn read_task(source: &str) -> Result { + // Reserve URL-shaped sources for a future explicit retrieval contract. + if source.contains("://") { + return Err("remote_task_not_supported"); + } + let mut bytes = Vec::new(); + let read = async { + if source == "-" { + tokio::io::stdin() + .take(MAX_TASK_BYTES + 1) + .read_to_end(&mut bytes) + .await + } else { + tokio::fs::File::open(source) + .await? + .take(MAX_TASK_BYTES + 1) + .read_to_end(&mut bytes) + .await + } + }; + tokio::time::timeout(INPUT_TIMEOUT, read) + .await + .map_err(|_| "task_input_timeout")? + .map_err(|_| "task_read_failed")?; + if bytes.len() as u64 > MAX_TASK_BYTES { + return Err("task_too_large"); + } + Task::parse(&bytes) +} + +pub(crate) async fn run() -> i32 { + let _ = rustls::crypto::ring::default_provider().install_default(); + let _ = tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "buzz_acp=info".into()), + ) + .compact() + .try_init(); + let args = RunArgs::try_parse_from(std::env::args_os().skip(1)); + let mut args = match args { + Ok(args) => args, + Err(error) + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) => + { + let _ = error.print(); + return 0; + } + Err(_) => return emit(Terminal::new("invalid", Some("invalid_arguments")), 2), + }; + // These settings belong only to conversation admission and repeated work. + args.launch.heartbeat_interval = 0; + args.launch.heartbeat_prompt = None; + args.launch.heartbeat_prompt_file = None; + args.launch.turn_liveness_secs = 0; + let (signal_tx, mut signal_rx) = tokio::sync::oneshot::channel(); + // Register before configuration: launch prompt files can also block on I/O. + #[cfg(unix)] + let signals = ( + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()), + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()), + ); + #[cfg(unix)] + let signal_task = match signals { + (Ok(mut interrupt), Ok(mut terminate)) => tokio::spawn(async move { + let code = tokio::select! { _ = interrupt.recv() => 130, _ = terminate.recv() => 143 }; + let _ = signal_tx.send(code); + }), + _ => return emit(Terminal::new("failed", Some("signal_setup_failed")), 1), + }; + #[cfg(not(unix))] + let signal_task = tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + let _ = signal_tx.send(130); + }); + // Config owns synchronous prompt-file reads. Keep them off the async worker + // so signals and the preparation bound remain live, including for FIFOs. + // This worker only builds configuration; it cannot spawn an agent or create + // runtime resources. On cancellation/timeout the CLI's bounded runtime + // shutdown and process exit also retire a still-blocked read. + let configuration = tokio::task::spawn_blocking(move || Config::from_args(args.launch)); + let config = tokio::select! { + biased; + code = &mut signal_rx => return emit(Terminal::new("cancelled", None), code.unwrap_or(1)), + result = tokio::time::timeout(INPUT_TIMEOUT, configuration) => match result { + Ok(Ok(Ok(config))) if config.max_turn_duration_secs > 0 => config, + Err(_) => { + signal_task.abort(); + return emit(Terminal::new("invalid", Some("configuration_timeout")), 2); + } + _ => { + signal_task.abort(); + return emit(Terminal::new("invalid", Some("invalid_configuration")), 2); + } + }, + }; + let task = tokio::select! { + biased; + code = &mut signal_rx => return emit(Terminal::new("cancelled", None), code.unwrap_or(1)), + task = read_task(&args.task) => task, + }; + let task = match task { + Ok(task) => task, + Err(error) => { + signal_task.abort(); + return emit(Terminal::new("invalid", Some(error)), 2); + } + }; + let mut terminal = Terminal::new("invalid", None); + terminal.task_id = Some(task.task_id); + if task.agent_pubkey != config.keys.public_key().to_hex() { + terminal.error = Some("agent_identity_mismatch"); + signal_task.abort(); + return emit(terminal, 2); + } + // Keep signing material alive until execution has drained and reaped the adapter. + let runtime = match AgentRuntime::prepare(config) { + Ok(environment) => environment, + Err(_) => { + terminal.status = "failed"; + terminal.error = Some("runtime_setup_failed"); + signal_task.abort(); + return emit(terminal, 1); + } + }; + let config = runtime.config(); + let rest = crate::relay::RestClient { + http: reqwest::Client::new(), + base_url: crate::relay::relay_ws_to_http(&config.relay_url), + keys: config.keys.clone(), + auth_tag_json: std::env::var("BUZZ_AUTH_TAG") + .ok() + .filter(|s| !s.is_empty()), + }; + let ctx = match runtime.prompt_context(rest, Default::default(), SessionMode::Task) { + Ok(ctx) => ctx, + Err(_) => { + terminal.error = Some("invalid_working_directory"); + return emit(terminal, 2); + } + }; + let startup = runtime.startup(None); + let duration = Duration::from_millis( + task.max_duration_ms + .min(config.max_turn_duration_secs.saturating_mul(1000)), + ); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let execution = isolated_execution::execute(&startup, &ctx, &task.prompt, duration, cancel_rx); + tokio::pin!(execution); + let (outcome, signal) = tokio::select! { + biased; + code = &mut signal_rx => { + let _ = cancel_tx.send(()); + (execution.await, Some(code.unwrap_or(1))) + } + outcome = &mut execution => (outcome, None), + }; + signal_task.abort(); + let code = if let Some(code) = signal { + terminal.status = "cancelled"; + code + } else { + match outcome { + Outcome::Completed(reason) => { + terminal.status = "completed"; + terminal.stop_reason = Some(reason); + 0 + } + Outcome::Cancelled => { + terminal.status = "cancelled"; + 1 + } + Outcome::Deadline => { + terminal.status = "timed_out"; + 124 + } + Outcome::Failed(error) => { + terminal.status = "failed"; + terminal.error = Some(error); + 1 + } + } + }; + emit(terminal, code) +} + +fn emit(terminal: Terminal, code: i32) -> i32 { + let mut out = std::io::stdout().lock(); + if serde_json::to_writer(&mut out, &terminal).is_err() + || out.write_all(b"\n").is_err() + || out.flush().is_err() + { + return 1; + } + code +} diff --git a/crates/buzz-acp/src/runtime.rs b/crates/buzz-acp/src/runtime.rs new file mode 100644 index 0000000000..e6745b6331 --- /dev/null +++ b/crates/buzz-acp/src/runtime.rs @@ -0,0 +1,125 @@ +//! Shared agent capabilities. Intake and scheduling belong to each entry point; +//! environment, credentials, adapter settings and prompt equipment belong here. +use crate::{ + build_mcp_servers, config::Config, current_working_directory, git, observer, pool, relay, + resolve_agent_owner, PromptContext, +}; +use anyhow::Result; +use std::{collections::HashMap, time::Duration}; +use uuid::Uuid; + +pub(crate) enum SessionMode { + Conversation, + Task, +} + +/// Own shared launch resources until all adapter work has been drained. +/// Add new runtime capabilities here, not separately to the CLI entry points. +pub(crate) struct AgentRuntime { + config: Config, + _git_environment: git::GitEnvironment, +} + +impl AgentRuntime { + pub(crate) fn prepare(mut config: Config) -> Result { + let git_environment = git::GitEnvironment::for_config(&mut config)?; + Ok(Self { + config, + _git_environment: git_environment, + }) + } + + pub(crate) fn config(&self) -> &Config { + &self.config + } + + pub(crate) fn startup(&self, observer: Option) -> PoolStartup { + PoolStartup::from_config(&self.config, observer) + } + + pub(crate) fn prompt_context( + &self, + rest: relay::RestClient, + channels: HashMap, + mode: SessionMode, + ) -> Result { + make_prompt_context(&self.config, rest, channels, mode) + } +} + +#[derive(Clone)] +pub(crate) struct PoolStartup { + pub(crate) agents: u32, + pub(crate) command: String, + pub(crate) args: Vec, + pub(crate) extra_env: Vec<(String, String)>, + pub(crate) has_generated_codex_config: bool, + pub(crate) model: Option, + pub(crate) effort_level: Option, + pub(crate) observer: Option, +} + +impl PoolStartup { + fn from_config(config: &Config, observer: Option) -> Self { + Self { + agents: config.agents, + command: config.agent_command.clone(), + args: config.agent_args.clone(), + extra_env: config.persona_env_vars.clone(), + has_generated_codex_config: config.has_generated_codex_config, + model: config.model.clone(), + effort_level: config.effort_level.clone(), + observer, + } + } +} + +fn make_prompt_context( + config: &Config, + rest_client: relay::RestClient, + channels: HashMap, + mode: SessionMode, +) -> Result { + let base_prompt_content = config.base_prompt_content.as_ref(); + let cwd = current_working_directory()?; + Ok(PromptContext { + mcp_servers: build_mcp_servers(config), + initial_message: config.initial_message.clone(), + idle_timeout: Duration::from_secs(config.idle_timeout_secs), + max_turn_duration: Duration::from_secs(config.max_turn_duration_secs), + turn_liveness_interval: Duration::from_secs(config.turn_liveness_secs), + dedup_mode: config.dedup_mode, + system_prompt: config.system_prompt.clone(), + session_title: config.session_title.clone(), + team_instructions: config.team_instructions.clone(), + base_prompt: if config.no_base_prompt { + None + } else { + // Build standing context once under the configured policy, before + // any session/new. Both modern ACP and legacy first-turn framing + // consume this same assembled base (including custom base files). + let base = base_prompt_content + .map(String::as_str) + .unwrap_or(include_str!("base_prompt.md")); + Some(if matches!(mode, SessionMode::Task) { + format!("{base}\n\n{}", include_str!("session_model_task.md")) + } else { + config.session_policy.append_session_model(base) + }) + }, + heartbeat_prompt: config.heartbeat_prompt.clone(), + cwd, + rest_client: rest_client.clone(), + channel_info: pool::ChannelInfoResolver::new(channels, rest_client), + context_message_limit: config.context_message_limit, + max_turns_per_session: config.max_turns_per_session, + permission_mode: config.permission_mode, + agent_keys: config.keys.clone(), + agent_owner_pubkey: resolve_agent_owner(config) + .as_deref() + .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), + memory_enabled: config.memory_enabled, + harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), + relay_url: config.relay_url.clone(), + }) +} diff --git a/crates/buzz-acp/src/session_model_task.md b/crates/buzz-acp/src/session_model_task.md new file mode 100644 index 0000000000..6110cb6146 --- /dev/null +++ b/crates/buzz-acp/src/session_model_task.md @@ -0,0 +1,11 @@ +## Task Session + +This is one fresh task session, not a channel conversation. The user prompt is +one prepared task. It can contain its own event evidence and reporting directions; +it need not contain a `` section. Follow that task rather than waiting +for an incoming message. No conversation history or channel routing is inherited. + +Base instructions about channel sessions and automatic incoming turns do not +apply here. Use the task's explicit context and available tools. Core memory, +files, and external effects can be shared with other sessions of this identity. +The harness does not automatically post your final text as a channel reply. diff --git a/crates/buzz-acp/tests/fixtures/task_agent.py b/crates/buzz-acp/tests/fixtures/task_agent.py new file mode 100644 index 0000000000..9e02145be9 --- /dev/null +++ b/crates/buzz-acp/tests/fixtures/task_agent.py @@ -0,0 +1,64 @@ +"""Deterministic ACP peer for the real run CLI integration tests. No model access.""" +import json +import os +import subprocess +import sys +import time + +mode = os.environ.get("TASK_AGENT_MODE", "normal") +log = open(os.environ["TASK_AGENT_LOG"], "a", buffering=1) +log.write(json.dumps({"pid": os.getpid(), "relay": os.environ.get("BUZZ_RELAY_URL"), + "key": os.environ.get("BUZZ_PRIVATE_KEY"), + "gitKeyfile": subprocess.check_output(["git", "config", "nostr.keyfile"], text=True).strip()}) + "\n") +if mode.startswith("descendant"): + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"]) + log.write(json.dumps({"descendant": child.pid}) + "\n") + +options = [ + {"id": "model", "name": "Model", "category": "model", "type": "select", + "currentValue": "old", "options": [{"value": "old", "name": "Old"}, {"value": "chosen", "name": "Chosen"}]}, + {"id": "effort", "name": "Effort", "category": "thought_level", "type": "select", + "currentValue": "low", "options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}]}, + {"id": "mode", "name": "Mode", "type": "select", "currentValue": "default", + "options": [{"value": "bypassPermissions", "name": "Bypass"}, {"value": "default", "name": "Default"}]}, +] +prompt_id = None +for line in sys.stdin: + msg = json.loads(line) + log.write(json.dumps(msg) + "\n") + method = msg.get("method") + result = {} + if method == "initialize": + if mode in ("startup-hang", "descendant-startup"): + time.sleep(120) + result = {"protocolVersion": int(os.environ.get("TASK_AGENT_PROTOCOL", "2")), + "agentInfo": {"name": "task-test"}, "agentCapabilities": {}} + elif method == "session/new": + if mode == "session-hang": + time.sleep(120) + result = {"sessionId": "fresh-" + str(os.getpid()), "configOptions": options, "modes": {"availableModes": [{"id": "bypassPermissions"}]}} + elif method == "session/set_config_option": + result = {"configOptions": options} + elif method == "session/prompt": + prompt_id = msg["id"] + if mode in ("turn-hang", "descendant-turn", "ignore-cancel"): + continue + if mode == "permission": + print(json.dumps({"jsonrpc": "2.0", "id": "permission", "method": "session/request_permission", "params": { + "sessionId": "fresh-" + str(os.getpid()), "toolCall": {"toolCallId": "tool-1", "title": "Test tool"}, + "options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"}]}}), flush=True) + continue + if mode == "error": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32000, "message": "SECRET_TASK_TEXT"}}), flush=True) + continue + result = {"stopReason": os.environ.get("TASK_STOP_REASON", "end_turn")} + elif method == "session/cancel": + if mode == "ignore-cancel": + continue + print(json.dumps({"jsonrpc": "2.0", "id": prompt_id, "result": {"stopReason": "end_turn"}}), flush=True) + continue + elif msg.get("id") == "permission": + print(json.dumps({"jsonrpc": "2.0", "id": prompt_id, "result": {"stopReason": "end_turn"}}), flush=True) + continue + if "id" in msg: + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": result}), flush=True) diff --git a/crates/buzz-acp/tests/git_bootstrap.rs b/crates/buzz-acp/tests/git_bootstrap.rs index dc6d25d04c..1db04e2bf6 100644 --- a/crates/buzz-acp/tests/git_bootstrap.rs +++ b/crates/buzz-acp/tests/git_bootstrap.rs @@ -8,6 +8,15 @@ use std::time::{Duration, Instant}; #[test] fn harness_native_git_and_startup_shutdown_cleanup() { + check_native_git_and_startup_shutdown_cleanup(false); +} + +#[test] +fn task_native_git_and_startup_shutdown_cleanup() { + check_native_git_and_startup_shutdown_cleanup(true); +} + +fn check_native_git_and_startup_shutdown_cleanup(task: bool) { let temp = tempfile::tempdir().unwrap(); let workspace = temp.path(); let adapter = workspace.join("adapter"); @@ -41,7 +50,25 @@ exec sleep 60 std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o700)).unwrap(); let keys = nostr::Keys::generate(); let log = std::fs::File::create(workspace.join("harness.log")).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-acp")) + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-acp")); + if task { + let path = workspace.join("task.json"); + std::fs::write( + &path, + serde_json::json!({ + "version": 1, "taskId": "git-probe", "agentPubkey": keys.public_key().to_hex(), + "prompt": "probe", "maxDurationMs": 30000 + }) + .to_string(), + ) + .unwrap(); + command + .arg("run") + .arg("--task") + .arg(path) + .arg("--no-memory"); + } + let mut child = command .env_clear() .env("PATH", std::env::var_os("PATH").unwrap_or_default()) .args([ @@ -110,7 +137,11 @@ exec sleep 60 let status = child.wait().unwrap(); let logs = std::fs::read_to_string(workspace.join("harness.log")).unwrap(); assert!(workspace.join("done").exists(), "probe failed: {logs}"); - assert!(status.success(), "startup shutdown failed: {logs}"); + assert_eq!( + status.code(), + Some(if task { 143 } else { 0 }), + "startup shutdown failed: {logs}" + ); let identity = std::fs::read_to_string(workspace.join("identity")).unwrap(); assert_eq!( identity, @@ -148,11 +179,35 @@ exec sleep 60 #[test] fn keyfile_removed_after_adapter_failure() { + check_keyfile_removed_after_adapter_failure(false); +} + +#[test] +fn task_keyfile_removed_after_adapter_failure() { + check_keyfile_removed_after_adapter_failure(true); +} + +fn check_keyfile_removed_after_adapter_failure(task: bool) { let temp = tempfile::tempdir().unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_buzz-acp")) + let keys = nostr::Keys::generate(); + let input = tempfile::NamedTempFile::new().unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-acp")); + if task { + std::fs::write( + input.path(), + serde_json::json!({ + "version": 1, "taskId": "failure", "agentPubkey": keys.public_key().to_hex(), + "prompt": "probe", "maxDurationMs": 10000 + }) + .to_string(), + ) + .unwrap(); + command.arg("run").arg("--task").arg(input.path()); + } + let output = command .args([ "--private-key", - &nostr::Keys::generate().secret_key().to_secret_hex(), + &keys.secret_key().to_secret_hex(), "--relay-url", "ws://localhost:1", "--agent-command", diff --git a/crates/buzz-acp/tests/run_task.rs b/crates/buzz-acp/tests/run_task.rs new file mode 100644 index 0000000000..4bf2b60540 --- /dev/null +++ b/crates/buzz-acp/tests/run_task.rs @@ -0,0 +1,579 @@ +//! Exercise the shipped entrypoint, not a second task runner in test code. +#![cfg(unix)] +use serde_json::{json, Value}; +use std::{ + fs, + io::Write, + path::PathBuf, + process::{Child, Command, Output, Stdio}, + time::{Duration, Instant}, +}; + +const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; +const PUBKEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +struct Fixture { + dir: PathBuf, +} +impl Fixture { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!("buzz-run-{}", uuid::Uuid::new_v4())); + fs::create_dir(&dir).unwrap(); + Self { dir } + } + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-acp")); + for (key, _) in std::env::vars().filter(|(k, _)| k.starts_with("BUZZ_")) { + command.env_remove(key); + } + command + .args([ + "run", + "--private-key", + KEY, + "--relay-url", + "ws://127.0.0.1:1", + "--agent-command", + "python3", + "--agent-args", + ]) + .arg(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/task_agent.py")) + .env("TASK_AGENT_LOG", self.dir.join("wire.jsonl")) + .current_dir(&self.dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + } + fn task(&self) -> Value { + json!({"version":1,"taskId":"local-1","agentPubkey":PUBKEY,"prompt":"prepared task","maxDurationMs":10000}) + } + fn run(&self, task: &Value, flags: &[&str], stdin: bool) -> Output { + let mut command = self.command(); + command.args(flags).arg("--task"); + if stdin { + command.arg("-"); + } else { + fs::write(self.dir.join("task.json"), task.to_string()).unwrap(); + command.arg(self.dir.join("task.json")); + } + let mut child = command.spawn().unwrap(); + if stdin { + child + .stdin + .take() + .unwrap() + .write_all(task.to_string().as_bytes()) + .unwrap(); + } + wait(child) + } + fn wire(&self) -> Vec { + fs::read_to_string(self.dir.join("wire.jsonl")) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} +fn wait(mut child: Child) -> Output { + let deadline = Instant::now() + Duration::from_secs(18); + loop { + if child.try_wait().unwrap().is_some() { + return child.wait_with_output().unwrap(); + } + if Instant::now() > deadline { + let _ = child.kill(); + panic!("runner exceeded bounded lifetime"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} +fn terminal(output: &Output, code: i32, status: &str) -> Value { + assert_eq!( + output.status.code(), + Some(code), + "stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + let value: Value = + serde_json::from_slice(&output.stdout).expect("exactly one JSON terminal record"); + assert_eq!(value["version"], 1); + assert_eq!(value["status"], status); + value +} +fn assert_dead(pid: u32) { + use nix::{sys::signal::kill, unistd::Pid}; + let deadline = Instant::now() + Duration::from_secs(2); + while kill(Pid::from_raw(pid as i32), None).is_ok() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + kill(Pid::from_raw(pid as i32), None).is_err(), + "child {pid} survived" + ); +} + +#[test] +fn file_and_stdin_run_one_fresh_equipped_session_without_service() { + for protocol in [1, 2] { + let f = Fixture::new(); + for stdin in [false, true] { + let mut command = f.command(); + command + .env("TASK_AGENT_PROTOCOL", protocol.to_string()) + .env("BUZZ_ACP_SETUP_PAYLOAD", "invalid-if-used") + .env( + "BUZZ_ACP_ISOLATED_TURN_SOCKET", + f.dir.join("must-not-exist.sock"), + ) + .env("BUZZ_PRIVATE_KEY", "invalid-inherited-key") + .args([ + "--no-memory", + "--system-prompt", + "PERSONA", + "--team-instructions", + "TEAM", + "--model", + "chosen", + "--effort-level", + "high", + "--mcp-command", + "/test/mcp", + "--session-title", + "task title", + "--agents", + "3", + "--heartbeat-interval", + "10", + "--initial-message", + "MUST_NOT_RUN", + "--task", + ]); + let task = f.task().to_string(); + fs::write(f.dir.join("task.json"), &task).unwrap(); + command.arg(if stdin { + PathBuf::from("-") + } else { + f.dir.join("task.json") + }); + let mut child = command.spawn().unwrap(); + if stdin { + child + .stdin + .take() + .unwrap() + .write_all(task.as_bytes()) + .unwrap(); + } + let result = terminal(&wait(child), 0, "completed"); + assert_eq!(result["stopReason"], "end_turn"); + assert!(!f.dir.join("must-not-exist.sock").exists()); + } + let wire = f.wire(); + let spawned: Vec<_> = wire.iter().filter(|v| v.get("pid").is_some()).collect(); + assert_eq!(spawned.len(), 2); + assert_ne!(spawned[0]["pid"], spawned[1]["pid"]); + for proc in spawned { + assert_eq!(proc["key"], KEY); + assert_eq!(proc["relay"], "ws://127.0.0.1:1"); + assert_dead(proc["pid"].as_u64().unwrap() as u32); + assert!(!std::path::Path::new(proc["gitKeyfile"].as_str().unwrap()).exists()); + } + let sessions: Vec<_> = wire + .iter() + .filter(|v| v["method"] == "session/new") + .collect(); + assert_eq!(sessions.len(), 2); + let prompts: Vec<_> = wire + .iter() + .filter(|v| v["method"] == "session/prompt") + .collect(); + assert_eq!(prompts.len(), 2); + for (session, prompt) in sessions.iter().zip(prompts) { + let setup = session["params"].to_string(); + assert!(setup.contains("/test/mcp")); + assert!(setup.contains("task title")); + let standing = if protocol == 2 { + setup + } else { + prompt["params"].to_string() + }; + assert!( + standing.contains("PERSONA") + && standing.contains("TEAM") + && standing.contains("") + && standing.contains("## Task Session"), + "{standing}" + ); + assert!(prompt["params"].to_string().contains("prepared task")); + assert!(!prompt["params"].to_string().contains("MUST_NOT_RUN")); + } + let options: Vec<_> = wire + .iter() + .filter(|v| v["method"] == "session/set_config_option") + .collect(); + for expected in ["chosen", "high", "bypassPermissions"] { + assert!( + options.iter().any(|v| v["params"]["value"] == expected), + "missing {expected}: {wire:?}" + ); + } + } +} + +#[test] +fn invalid_input_never_spawns() { + let f = Fixture::new(); + let valid = f.task(); + for (field, value) in [ + ("version", json!(2)), + ("taskId", json!("")), + ("prompt", json!(" ")), + ("agentPubkey", json!("0".repeat(64))), + ("maxDurationMs", json!(0)), + ("command", json!("malicious")), + ] { + let mut task = valid.clone(); + task[field] = value; + terminal(&f.run(&task, &["--no-memory"], true), 2, "invalid"); + } + for source in ["https://example.test/task", "./missing.json"] { + let mut command = f.command(); + command.args(["--task", source]); + terminal(&wait(command.spawn().unwrap()), 2, "invalid"); + } + for bytes in [b"{} {}".to_vec(), b"{".to_vec()] { + let mut command = f.command(); + command.args(["--task", "-"]); + let mut child = command.spawn().unwrap(); + child.stdin.take().unwrap().write_all(&bytes).unwrap(); + terminal(&wait(child), 2, "invalid"); + } + assert!(f.wire().is_empty()); +} + +#[test] +fn oversized_file_and_stdin_report_task_too_large_before_spawn() { + let f = Fixture::new(); + let bytes = vec![b' '; 1024 * 1024 + 1]; + fs::write(f.dir.join("oversized.json"), &bytes).unwrap(); + for stdin in [false, true] { + let mut command = f.command(); + command.arg("--task").arg(if stdin { + PathBuf::from("-") + } else { + f.dir.join("oversized.json") + }); + let mut child = command.spawn().unwrap(); + if stdin { + child.stdin.take().unwrap().write_all(&bytes).unwrap(); + } + let result = terminal(&wait(child), 2, "invalid"); + assert_eq!(result["error"], "task_too_large"); + assert_eq!(result["taskId"], Value::Null); + assert!(f.wire().is_empty()); + } +} + +#[test] +fn blocked_configuration_reads_are_cancellable_and_bounded() { + use nix::{ + sys::signal::{kill, Signal}, + unistd::Pid, + }; + use std::os::unix::fs::OpenOptionsExt; + + for flag in ["--system-prompt-file", "--base-prompt-file"] { + for signal in [Some(Signal::SIGINT), Some(Signal::SIGTERM), None] { + let f = Fixture::new(); + let fifo = f.dir.join("prompt.fifo"); + assert!(Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success()); + let started = Instant::now(); + let child = f + .command() + .arg(flag) + .arg(&fifo) + .args(["--no-memory", "--task", "-"]) + .spawn() + .unwrap(); + // A successful nonblocking writer open proves Config has opened + // this FIFO for reading. Hold it without data, so read_to_string + // cannot finish. No sleep-based assumption about startup readiness. + let until = Instant::now() + Duration::from_secs(5); + let writer = loop { + match fs::OpenOptions::new() + .write(true) + .custom_flags(nix::libc::O_NONBLOCK) + .open(&fifo) + { + Ok(writer) => break writer, + Err(error) if error.raw_os_error() == Some(nix::libc::ENXIO) => { + assert!(Instant::now() < until, "configuration never opened FIFO"); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("FIFO writer: {error}"), + } + }; + if let Some(signal) = signal { + let signalled = Instant::now(); + kill(Pid::from_raw(child.id() as i32), signal).unwrap(); + let code = if signal == Signal::SIGINT { 130 } else { 143 }; + let result = terminal(&wait(child), code, "cancelled"); + assert_eq!(result["taskId"], Value::Null); + assert!(signalled.elapsed() < Duration::from_secs(3)); + } else { + let result = terminal(&wait(child), 2, "invalid"); + assert_eq!(result["error"], "configuration_timeout"); + assert!(started.elapsed() < Duration::from_secs(15)); + } + drop(writer); + assert!(f.wire().is_empty()); + } + } +} + +#[test] +fn stop_reasons_failures_and_permission_exchange_are_explicit() { + for (mode, reason, code, status) in [ + ("normal", "refusal", 0, "completed"), + ("normal", "max_tokens", 0, "completed"), + ("error", "", 1, "failed"), + ("permission", "end_turn", 0, "completed"), + ] { + let f = Fixture::new(); + let mut command = f.command(); + command + .env("TASK_AGENT_MODE", mode) + .env("TASK_STOP_REASON", reason) + .args(["--no-memory", "--task", "-"]); + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(f.task().to_string().as_bytes()) + .unwrap(); + let output = wait(child); + let result = terminal(&output, code, status); + assert!(!String::from_utf8_lossy(&output.stdout).contains("SECRET_TASK_TEXT")); + if code == 0 { + assert_eq!(result["stopReason"], reason); + } + if mode == "permission" { + assert!(f + .wire() + .iter() + .any(|v| v["id"] == "permission" && v.get("result").is_some())); + } + } +} + +#[test] +fn deadline_covers_initialize_session_and_turn_and_cleans_descendants() { + for mode in [ + "descendant-startup", + "session-hang", + "descendant-turn", + "ignore-cancel", + ] { + let f = Fixture::new(); + let mut task = f.task(); + task["maxDurationMs"] = json!(350); + let mut command = f.command(); + command + .env("TASK_AGENT_MODE", mode) + .args(["--no-memory", "--task", "-"]); + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(task.to_string().as_bytes()) + .unwrap(); + terminal(&wait(child), 124, "timed_out"); + for frame in f.wire() { + for key in ["pid", "descendant"] { + if let Some(pid) = frame[key].as_u64() { + assert_dead(pid as u32); + } + } + } + } +} + +#[test] +fn signals_cancel_startup_turn_and_pending_stdin_without_hanging() { + use nix::{ + sys::signal::{kill, Signal}, + unistd::Pid, + }; + for (mode, signal, code) in [ + ("startup-hang", Signal::SIGINT, 130), + ("turn-hang", Signal::SIGTERM, 143), + ("stdin", Signal::SIGTERM, 143), + ] { + let f = Fixture::new(); + let mut command = f.command(); + command + .env("TASK_AGENT_MODE", mode) + .args(["--no-memory", "--task", "-"]); + let mut child = command.spawn().unwrap(); + let held_stdin = if mode == "stdin" { + child.stdin.take() + } else { + child + .stdin + .take() + .unwrap() + .write_all(f.task().to_string().as_bytes()) + .unwrap(); + None + }; + let until = Instant::now() + Duration::from_secs(3); + if mode == "stdin" { + std::thread::sleep(Duration::from_millis(250)); + } else { + let method = if mode == "turn-hang" { + "session/prompt" + } else { + "initialize" + }; + while !f.wire().iter().any(|v| v["method"] == method) { + assert!(Instant::now() < until); + std::thread::sleep(Duration::from_millis(10)); + } + } + kill(Pid::from_raw(child.id() as i32), signal).unwrap(); + terminal(&wait(child), code, "cancelled"); + drop(held_stdin); + for frame in f.wire() { + if let Some(pid) = frame["pid"].as_u64() { + assert_dead(pid as u32); + } + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn memory_is_loaded_before_session_and_opt_out_makes_no_request() { + use axum::{body::Bytes, http::HeaderMap, routing::post, Router}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + for (body, expected) in [("[]", true), ("not-json", false)] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let unexpected = Arc::new(AtomicUsize::new(0)); + let rejected = unexpected.clone(); + // Use the real HTTP parser rather than byte-wise timed socket reads. + // Idle connections and partial packets cannot block another request. + let app = Router::new() + .route( + "/query", + post(move |headers: HeaderMap, bytes: Bytes| { + let observed = observed.clone(); + async move { + assert!(headers["authorization"] + .to_str() + .unwrap() + .starts_with("Nostr ")); + assert!(String::from_utf8(bytes.to_vec()).unwrap().contains("30174")); + observed.fetch_add(1, Ordering::SeqCst); + body + } + }), + ) + .fallback(move || { + rejected.fetch_add(1, Ordering::SeqCst); + async { axum::http::StatusCode::NOT_FOUND } + }); + let (stop, stopped) = tokio::sync::oneshot::channel(); + let mut server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .await + .unwrap(); + }); + for disabled in [false, true] { + let f = Fixture::new(); + let command = f.command(); + // Remove the fixture's default URL, retaining the rest of the argv. + let argv: Vec<_> = command.get_args().map(|s| s.to_os_string()).collect(); + let mut command = Command::new(command.get_program()); + for (k, v) in f.command().get_envs() { + match v { + Some(v) => { + command.env(k, v); + } + None => { + command.env_remove(k); + } + } + } + command + .args(argv.iter().map(|s| { + if s == "ws://127.0.0.1:1" { + std::ffi::OsStr::new(&url) + } else { + s.as_os_str() + } + })) + .args(["--agent-owner", PUBKEY, "--task", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if disabled { + command.arg("--no-memory"); + } + let before = calls.load(Ordering::SeqCst); + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(f.task().to_string().as_bytes()) + .unwrap(); + terminal(&wait(child), 0, "completed"); + assert_eq!( + calls.load(Ordering::SeqCst) - before, + usize::from(!disabled) + ); + let wire = f.wire(); + let session = wire.iter().find(|v| v["method"] == "session/new").unwrap(); + assert_eq!( + session.to_string().contains(""), + expected && !disabled + ); + } + assert_eq!( + unexpected.load(Ordering::SeqCst), + 0, + "unexpected service access" + ); + stop.send(()).unwrap(); + match tokio::time::timeout(Duration::from_secs(2), &mut server).await { + Ok(result) => result.unwrap(), + Err(_) => { + server.abort(); + let _ = server.await; + panic!("memory server did not drain"); + } + } + } +} diff --git a/crates/buzz-acp/tests/stdio_contract.rs b/crates/buzz-acp/tests/stdio_contract.rs new file mode 100644 index 0000000000..047607d574 --- /dev/null +++ b/crates/buzz-acp/tests/stdio_contract.rs @@ -0,0 +1,35 @@ +use std::process::Command; + +#[test] +fn startup_and_failure_diagnostics_never_enter_acp_stdout() { + let output = Command::new(env!("CARGO_BIN_EXE_buzz-acp")) + .env("BUZZ_RELAY_URL", "ws://127.0.0.1:1") + .env( + "BUZZ_PRIVATE_KEY", + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .env("BUZZ_AGENT_COMMAND", "/definitely/missing/buzz-agent") + .env("RUST_LOG", "buzz_acp=info") + .output() + .expect("buzz-acp process should start"); + + assert!( + !output.status.success(), + "unreachable relay must fail startup" + ); + assert!( + output.stdout.is_empty(), + "ACP stdout contained diagnostics: {:?}", + String::from_utf8_lossy(&output.stdout) + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("buzz-acp starting:"), + "missing startup INFO: {stderr}" + ); + assert!( + stderr.contains("Error:") || stderr.contains("error"), + "missing failure diagnostic: {stderr}" + ); +} diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 8f90c3e445..38152b3f90 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -27,8 +27,9 @@ use crate::{ /// A discovered model entry: `id` is the picker value (the raw endpoint id or /// Unity Catalog model-service FQN, and the wire/config value), `name` is the /// display label. Databricks catalog APIs do not provide a consistently useful -/// picker label, so discovery curates names from the capability manifest when -/// an exact known id exists and otherwise uses the raw id. +/// picker label, so discovery derives `name` as exact record → unique alias → +/// generated label grammar (known `label_family_tokens` families only) → raw +/// id. Adding a vendor means adding one `label_family_tokens` entry. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelEntry { pub id: String, @@ -80,12 +81,10 @@ const UNITY_CATALOG_DESCRIPTOR: CatalogDescriptor = CatalogDescripto parse_page: parse_uc_model_services_page, }; -/// Curated display label for a discovered Databricks endpoint or model-service -/// id. Unknown ids deliberately pass through unchanged. +/// Display label for a discovered Databricks endpoint or model-service id. Ids +/// the label grammar cannot fully parse deliberately pass through unchanged. fn curated_model_name(id: &str) -> String { - crate::model_capabilities::databricks_registry_label(id) - .unwrap_or(id) - .to_string() + crate::model_capabilities::databricks_registry_label(id).unwrap_or_else(|| id.to_string()) } /// Fallback catalog used only when both authenticated Databricks v2 catalogs @@ -1903,7 +1902,7 @@ mod tests { // `name` is the curated label + provenance suffix, not the raw id. assert!(models.iter().all(|model| { let label = crate::model_capabilities::databricks_registry_label(&model.id) - .unwrap_or(model.id.as_str()); + .unwrap_or_else(|| model.id.clone()); model.name == format!("{label}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}") })); } diff --git a/crates/buzz-agent/src/databricks_label_grammar.rs b/crates/buzz-agent/src/databricks_label_grammar.rs new file mode 100644 index 0000000000..0a30b9b9f7 --- /dev/null +++ b/crates/buzz-agent/src/databricks_label_grammar.rs @@ -0,0 +1,281 @@ +//! Deterministic display-label grammar for Databricks endpoint ids. +//! +//! The last tier of [`crate::model_capabilities::databricks_registry_label`], +//! reached only after an exact-record miss and a unique-alias miss. The result +//! is either a label built solely from the id's own tokens or `None`, in which +//! case callers show the raw id. It is presentation-only: capabilities, wire +//! routing, and the saved model id never depend on it. Ids from families not +//! in the manifest's `label_family_tokens` stay raw; supporting a new vendor +//! means adding one entry to that list, not a record per model. +//! +//! Mirrored by `desktop/src/features/agents/ui/modelCapabilities.ts`; both +//! replay `scripts/databricks-label-fixtures.json`. + +use crate::model_capabilities::is_databricks_model_service_fqn; + +/// Endpoint-name wrappers that precede the model name in Databricks ids. +const WRAPPERS: [&str; 4] = ["databricks-", "goose-", "kgoose-", "builderbot-"]; + +enum Part { + /// Numeric version (`5`, `5.5`) — the one part GPT/GLM join with a hyphen. + Version(String), + Word(String), +} + +/// Parse a Databricks endpoint id into a display label, or `None` when any part +/// of the id falls outside the grammar. Only families listed in the manifest's +/// `label_family_tokens` are named, so custom endpoints (`rag-agent-3`) stay raw. +pub(crate) fn generate_databricks_label( + raw_model_id: &str, + family_tokens: &[String], +) -> Option { + // Refuse non-ASCII before trimming or folding, so no Unicode case or + // whitespace rule can turn an unsupported id into an ASCII-looking one. + if !raw_model_id.is_ascii() { + return None; + } + let id = raw_model_id.trim().to_ascii_lowercase(); + let is_fqn = is_databricks_model_service_fqn(&id); + let service = if is_fqn { + id.rsplit('.').next()? + } else { + id.as_str() + }; + // Only a UC FQN or a wrapped endpoint name is attributable to Databricks; a + // bare id such as `gpt-5` stays raw. + let body = match WRAPPERS.iter().find_map(|w| service.strip_prefix(w)) { + Some(body) => body, + None if is_fqn => service, + None => return None, + }; + let body = body + .strip_prefix("meta-") + .filter(|rest| rest.starts_with("llama-")) + .unwrap_or(body); + + let mut tokens = body.split('-'); + let (family, stem) = split_family(tokens.next()?)?; + if !family_tokens + .iter() + .any(|t| t.strip_suffix('-').unwrap_or(t) == family) + { + return None; + } + let mut rest: Vec<&str> = tokens.collect(); + // Attached digits followed by a version (`qwen3-5`, `llama3-1`) cannot be + // read without guessing; checked before any reorder. + if stem.is_some() && rest.first().is_some_and(|t| is_version(t)) { + return None; + } + if family == "claude" { + // Goose's numeric-first Claude ids (`claude-4-7-opus`) name the tier + // after the version. Reorder only that exact shape; any other token + // after the version keeps its position. + let digits = rest.iter().take_while(|t| is_version(t)).count(); + if (1..=2).contains(&digits) + && rest + .get(digits) + .is_some_and(|t| matches!(*t, "opus" | "sonnet" | "haiku")) + { + rest[..=digits].rotate_right(1); + } + } + + let mut parts = Vec::new(); + let mut has_number = stem.is_some(); + let mut after_minor = false; + let mut rest = rest.into_iter().peekable(); + while let Some(tok) = rest.next() { + if is_date(tok) && rest.peek().is_none() { + break; + } + let part = if is_version(tok) { + let minor = rest.next_if(|t| is_version(t)); + after_minor = minor.is_some(); + if after_minor && rest.peek().is_some_and(|t| is_version(t)) { + // A third number (`3-7-1`) would read as a separate version. + return None; + } + Part::Version(minor.map_or_else(|| tok.to_string(), |m| format!("{tok}.{m}"))) + } else if is_letter_version(tok) { + let minor = rest.next_if(|t| is_version(t)); + if minor.is_some() && rest.peek().is_some_and(|t| is_version(t)) { + return None; + } + let major = capitalize(tok); + Part::Word(minor.map_or_else(|| major.clone(), |m| format!("{major}.{m}"))) + } else if is_size(tok) { + Part::Word(tok.to_ascii_uppercase()) + } else if !tok.is_empty() && tok.bytes().all(|b| b.is_ascii_lowercase()) { + Part::Word(match tok { + "oss" => "OSS".to_string(), + "mini" | "nano" if family == "gpt" && after_minor => tok.to_string(), + _ => capitalize(tok), + }) + } else { + return None; + }; + has_number |= match &part { + Part::Version(_) => true, + Part::Word(word) => word.bytes().any(|b| b.is_ascii_digit()), + }; + parts.push(part); + } + // A versionless id (`builderbot-pr-reviews`) is a named endpoint, not a model. + if !has_number { + return None; + } + + let (brand, hyphenated) = match family { + "gpt" => ("GPT".to_string(), true), + "glm" => ("GLM".to_string(), true), + "deepseek" => ("DeepSeek".to_string(), false), + _ => (capitalize(family), false), + }; + let mut label = brand + &stem.unwrap_or_default(); + for (index, part) in parts.iter().enumerate() { + let (sep, text) = match part { + Part::Version(v) if index == 0 && hyphenated => ('-', v), + Part::Version(text) | Part::Word(text) => (' ', text), + }; + label.push(sep); + label.push_str(text); + } + Some(label) +} + +/// Split the leading token into its family name and an optional in-name +/// version, kept whole (`nova12` → `12`). The one exception is Qwen's own +/// compact-decimal naming: `qwen35` → `3.5` when the second digit is nonzero. +fn split_family(tok: &str) -> Option<(&str, Option)> { + let alpha = tok.bytes().take_while(u8::is_ascii_lowercase).count(); + let (family, digits) = tok.split_at(alpha); + if family.is_empty() || digits.len() > 2 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let stem = match digits.as_bytes() { + [] => None, + [major, minor] if family == "qwen" && *minor != b'0' => { + Some(format!("{}.{}", *major as char, *minor as char)) + } + _ => Some(digits.to_string()), + }; + Some((family, stem)) +} + +/// `0` or a 1–2-digit number without a leading zero. +fn is_version(tok: &str) -> bool { + match tok.as_bytes() { + [d] => d.is_ascii_digit(), + [a, b] => (b'1'..=b'9').contains(a) && b.is_ascii_digit(), + _ => false, + } +} + +/// A 4- or 8-digit date stamp (`0731`, `20260101`). +fn is_date(tok: &str) -> bool { + matches!(tok.len(), 4 | 8) && tok.bytes().all(|b| b.is_ascii_digit()) +} + +/// A letter-prefixed version such as `v4` or `k2`. +fn is_letter_version(tok: &str) -> bool { + matches!(tok.as_bytes(), [l, digits @ ..] if l.is_ascii_lowercase() + && !digits.is_empty() + && digits.iter().all(u8::is_ascii_digit)) +} + +/// A parameter-size token: `120b`, or letter-prefixed `a3b`. +fn is_size(tok: &str) -> bool { + let Some(body) = tok.strip_suffix('b') else { + return false; + }; + let digits = body + .strip_prefix(|c: char| c.is_ascii_lowercase()) + .unwrap_or(body); + !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) +} + +fn capitalize(word: &str) -> String { + let mut chars = word.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_ascii_uppercase().to_string() + chars.as_str() + }) +} + +#[cfg(test)] +mod tests { + use crate::model_capabilities::{databricks_curated_label, databricks_registry_label}; + use serde_json::Value; + + const FIXTURES_JSON: &str = include_str!("../../../scripts/databricks-label-fixtures.json"); + const MANIFEST_JSON: &str = include_str!("../../../scripts/model-capabilities.json"); + + fn generate(id: &str) -> Option { + let family_tokens: Vec = + serde_json::from_value(json(MANIFEST_JSON)["label_family_tokens"].clone()) + .expect("label_family_tokens"); + super::generate_databricks_label(id, &family_tokens) + } + + fn json(source: &str) -> Value { + serde_json::from_str(source).expect("fixture JSON must parse") + } + + /// `(raw_model_id, registry_label)` for every `databricks_v2` exact record. + fn exact_records() -> Vec<(String, String)> { + json(MANIFEST_JSON)["exact_records"] + .as_array() + .expect("exact_records") + .iter() + .filter(|rec| rec["provider"] == "databricks_v2") + .map(|rec| { + let field = |key: &str| rec[key].as_str().expect(key).to_string(); + (field("raw_model_id"), field("registry_label")) + }) + .collect() + } + + #[test] + fn shared_fixtures_replay_through_the_label_helper() { + let fixtures = json(FIXTURES_JSON); + let records = exact_records(); + for row in fixtures["lookup"].as_array().expect("lookup") { + let id = row["id"].as_str().expect("id"); + let label = row["label"].as_str(); + assert_eq!(databricks_registry_label(id).as_deref(), label, "id={id:?}"); + let has_exact = records.iter().any(|(raw, _)| raw.eq_ignore_ascii_case(id)); + let curated = databricks_curated_label(id); + let tier = row["tier"].as_str().expect("tier"); + assert_eq!(has_exact, tier == "exact", "tier={tier} id={id:?}"); + match tier { + "exact" | "alias" => assert_eq!(curated.as_deref(), label, "id={id:?}"), + "generated" => { + assert_eq!(curated, None, "generated fixture is masked: {id:?}"); + assert_eq!(generate(id).as_deref(), label, "id={id:?}"); + } + "raw" => { + assert_eq!(label, None, "raw fixture has a label: {id:?}"); + assert_eq!(curated, None, "raw fixture is curated: {id:?}"); + } + tier => panic!("unknown tier {tier:?} for {id}"), + } + } + } + + #[test] + fn grammar_reproduces_every_curated_label_except_curator_only_ones() { + let fixtures = json(FIXTURES_JSON); + let curator_only: Vec<&str> = fixtures["curator_only_labels"] + .as_array() + .expect("curator_only_labels") + .iter() + .map(|id| id.as_str().expect("id")) + .collect(); + let misses: Vec = exact_records() + .into_iter() + .filter(|(raw, label)| generate(raw).as_deref() != Some(label)) + .map(|(raw, _)| raw) + .collect(); + assert_eq!(misses, curator_only); + } +} diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 01ee90a1d9..2c9c0f2036 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -6,6 +6,7 @@ mod builtin; pub mod catalog; pub mod config; pub mod databricks; +mod databricks_label_grammar; mod handoff; mod hints; mod llm; @@ -413,8 +414,7 @@ async fn resolve_models_catalog( fn configured_model_fallback(model: &str) -> Vec { let model = model.trim().to_string(); let name = crate::model_capabilities::databricks_registry_label(&model) - .unwrap_or(&model) - .to_string(); + .unwrap_or_else(|| model.clone()); vec![ModelEntry { id: model, name }] } diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs index 207544a338..08be4f020f 100644 --- a/crates/buzz-agent/src/model_capabilities.rs +++ b/crates/buzz-agent/src/model_capabilities.rs @@ -459,23 +459,41 @@ pub fn databricks_v2_known_models() -> &'static [String] { &manifest().databricks_v2_known_models } -/// Curated display label for a Databricks endpoint id, or `None` when no exact -/// record covers it. Exact raw-id hits preserve the resolver's current behavior. -/// On an exact miss, aliases share a label only when stripping the manifest's -/// existing family-token prefix from the query and record keys yields exactly one -/// `databricks_v2` record; no or ambiguous stripped matches deliberately remain -/// uncurated. This accessor is discovery-only, so `resolve()` retains its exact- -/// record label contract. -pub fn databricks_registry_label(raw_model_id: &str) -> Option<&'static str> { +/// Display label for a Databricks endpoint id, or `None` to show the raw id. +/// Precedence: exact record → unique alias (family-token-stripped query and +/// record keys match exactly one `databricks_v2` record) → the generative label +/// grammar. An ambiguous alias match stays `None` rather than generating. This +/// accessor is display-only, so `resolve()` retains its exact-record label +/// contract. +pub fn databricks_registry_label(raw_model_id: &str) -> Option { let m = manifest(); - registry_label_for_databricks_records(raw_model_id, &m.exact_records, &m.label_family_tokens) + registry_label_for_databricks_records( + raw_model_id, + &m.exact_records, + &m.label_family_tokens, + true, + ) } -fn registry_label_for_databricks_records<'a>( +/// Exact-record and unique-alias tiers only, so tests can assert which tier +/// produced a label. +#[cfg(test)] +pub(crate) fn databricks_curated_label(raw_model_id: &str) -> Option { + let m = manifest(); + registry_label_for_databricks_records( + raw_model_id, + &m.exact_records, + &m.label_family_tokens, + false, + ) +} + +fn registry_label_for_databricks_records( raw_model_id: &str, - records: &'a [ExactRecord], + records: &[ExactRecord], family_tokens: &[String], -) -> Option<&'a str> { + generate: bool, +) -> Option { if raw_model_id.trim().is_empty() { return None; } @@ -483,13 +501,23 @@ fn registry_label_for_databricks_records<'a>( if let Some(rec) = records.iter().find(|rec| { rec.provider == "databricks_v2" && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) }) { - return Some(&rec.registry_label); + return Some(rec.registry_label.clone()); } + let generated = || { + generate + .then(|| { + crate::databricks_label_grammar::generate_databricks_label( + raw_model_id, + family_tokens, + ) + }) + .flatten() + }; let query_lower = raw_model_id.to_ascii_lowercase(); let stripped_query = strip_catalog_prefix(&query_lower, family_tokens); if stripped_query == query_lower { - return None; + return generated(); } let mut matching_record = None; for rec in records.iter().filter(|rec| rec.provider == "databricks_v2") { @@ -500,7 +528,7 @@ fn registry_label_for_databricks_records<'a>( return None; } } - matching_record.map(|rec| rec.registry_label.as_str()) + matching_record.map_or_else(generated, |rec| Some(rec.registry_label.clone())) } /// Semantic invariants that strict typed parsing cannot express. Structural @@ -1085,7 +1113,7 @@ mod tests { fn test_databricks_registry_label_lookup() { // Exact raw id remains case-insensitive and unchanged. assert_eq!( - databricks_registry_label("DATABRICKS-GPT-5-5"), + databricks_registry_label("DATABRICKS-GPT-5-5").as_deref(), Some("GPT-5.5") ); // Exact raw ids preserve their canonical labels. @@ -1095,18 +1123,18 @@ mod tests { ("databricks-kimi-k3", "Kimi K3"), ] { assert_eq!( - databricks_registry_label(model), + databricks_registry_label(model).as_deref(), Some(label), "model={model}" ); } // Aliases reuse the existing family-token stripper. assert_eq!( - databricks_registry_label("goose-gpt-5-6-sol"), + databricks_registry_label("goose-gpt-5-6-sol").as_deref(), Some("GPT-5.6 Sol") ); assert_eq!( - databricks_registry_label("goose-claude-fable-5"), + databricks_registry_label("goose-claude-fable-5").as_deref(), Some("Claude Fable 5") ); for (alias, label) in [ @@ -1119,7 +1147,7 @@ mod tests { ("goose-kimi-k3", "Kimi K3"), ] { assert_eq!( - databricks_registry_label(alias), + databricks_registry_label(alias).as_deref(), Some(label), "alias={alias}" ); @@ -1149,7 +1177,11 @@ mod tests { ("system.ai.glm-5-3-flash", "GLM-5.3 Flash"), ("system.ai.grok-4-6", "Grok 4.6"), ] { - assert_eq!(databricks_registry_label(fqn), Some(label), "fqn={fqn}"); + assert_eq!( + databricks_registry_label(fqn).as_deref(), + Some(label), + "fqn={fqn}" + ); } // Unknown ids, bare family ids, and blanks remain uncurated. assert_eq!(databricks_registry_label("custom-unlisted-endpoint"), None); @@ -1182,7 +1214,7 @@ mod tests { let family_tokens = vec!["gpt-".to_string()]; assert_eq!( - registry_label_for_databricks_records("goose-gpt-5-6", &records, &family_tokens), + registry_label_for_databricks_records("goose-gpt-5-6", &records, &family_tokens, true), None ); } diff --git a/crates/buzz-db/src/store/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs index c5a7ea542f..a6a92c6a9a 100644 --- a/crates/buzz-db/src/store/admin_moderation.rs +++ b/crates/buzz-db/src/store/admin_moderation.rs @@ -38,6 +38,10 @@ pub struct AdminReport { pub target_kind: String, /// Hex target identifier. pub target: String, + /// Author of the reported event when `target_kind` is `"event"` and the + /// event is stored (deleted events included), so a list row can name the + /// reported person without a per-report detail read. + pub target_author_pubkey: Option, /// Optional channel. pub channel_id: Option, /// NIP-56 report category. @@ -195,9 +199,19 @@ pub async fn list_reports( r.report_event_id, r.reporter_pubkey, r.target_kind, r.target_event_id, r.target_pubkey, r.target_blob_sha256, r.channel_id, r.report_type, r.note, r.status, r.resolved_by, - r.resolved_at, r.action_id, r.created_at + r.resolved_at, r.action_id, r.created_at, + target.pubkey AS target_author_pubkey FROM moderation_reports r JOIN communities c ON c.id = r.community_id + LEFT JOIN LATERAL ( + SELECT e.pubkey + FROM events e + WHERE r.target_kind = 'event' + AND e.community_id = r.community_id + AND e.id = r.target_event_id + ORDER BY e.created_at DESC + LIMIT 1 + ) target ON TRUE WHERE ($1::uuid IS NULL OR r.community_id = $1) AND ($2::text IS NULL OR r.status = $2) AND ($3::text IS NULL OR r.report_type = $3) @@ -233,6 +247,7 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result Result { reporter_pubkey: hex::encode(row.try_get::, _>("reporter_pubkey")?), target_kind, target: hex::encode(target), + target_author_pubkey: row + .try_get::>, _>("target_author_pubkey")? + .map(hex::encode), channel_id: row.try_get("channel_id")?, report_type: row.try_get("report_type")?, note: row.try_get("note")?, @@ -628,6 +646,67 @@ mod postgres_tests { .expect("delete community fixtures"); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_reports_names_same_community_event_author_including_deleted() { + let pool = setup_pool().await; + let report_community = insert_community(&pool, "list-author").await; + let other_community = insert_community(&pool, "list-author-other").await; + let event_id = vec![2_u8; 32]; + insert_event( + &pool, + report_community, + &event_id, + &[5_u8; 32], + "gone", + Some(Utc::now()), + ) + .await; + insert_event( + &pool, + other_community, + &event_id, + &[6_u8; 32], + "wrong tenant", + None, + ) + .await; + let event_report = insert_event_report(&pool, report_community, &event_id).await; + let pubkey_report = insert_pubkey_report(&pool, report_community).await; + + let reports = list_reports( + &pool, + Some(report_community), + None, + None, + None, + None, + None, + None, + 50, + ) + .await + .expect("list reports"); + let author_of = |id: Uuid| { + reports + .iter() + .find(|r| r.id == id) + .expect("report listed") + .target_author_pubkey + .clone() + }; + assert_eq!(author_of(event_report), Some(hex::encode([5_u8; 32]))); + assert_eq!(author_of(pubkey_report), None); + + sqlx::query("DELETE FROM events WHERE community_id = ANY($1)") + .bind(vec![report_community, other_community]) + .execute(&pool) + .await + .expect("delete event fixtures"); + delete_report_fixture(&pool, report_community).await; + delete_report_fixture(&pool, other_community).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn report_detail_has_no_message_for_non_event_target() { diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 542a6d80fa..411c518240 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -995,14 +995,37 @@ pub async fn soft_delete_event_and_update_thread( parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { - use crate::store::replaceable::event_replacement_lock_key; - let connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::EventWrite, ) .await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + let deleted = soft_delete_event_and_update_thread_in_tx( + &mut tx, + community_id, + event_id, + parent_event_id, + root_event_id, + ) + .await?; + tx.commit().await?; + Ok(deleted) +} + +/// Transaction-scoped body of [`soft_delete_event_and_update_thread`]. +/// +/// Callers that must fence the delete with their own writes (e.g. the admin +/// action lease/marker) run this inside their transaction; the caller commits. +pub(crate) async fn soft_delete_event_and_update_thread_in_tx( + tx: &mut PgConnection, + community_id: CommunityId, + event_id: &[u8], + parent_event_id: Option<&[u8]>, + root_event_id: Option<&[u8]>, +) -> Result { + use crate::store::replaceable::event_replacement_lock_key; + // Derive the target event's kind and channel_id inside the transaction so // that the serialization decision cannot be bypassed by any caller. let target: Option<(i32, Option)> = sqlx::query_as( @@ -1065,7 +1088,6 @@ pub async fn soft_delete_event_and_update_thread( } } - tx.commit().await?; Ok(deleted) } diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index f50ab385b4..cc85f30db3 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -480,6 +480,15 @@ pub async fn execute_ban_with_marker( return Ok(false); } + // Acquire the action-row lock before evaluating the wall-clock expiry check. + // PostgreSQL can evaluate clock_timestamp() in the marker UPDATE predicate + // before waiting on the row; a lock acquired here ensures expiry is checked + // under the lock, not before it. + sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") + .bind(action_id) + .fetch_optional(&mut *tx) + .await?; + sqlx::query( r#" INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey, ban_reason) @@ -502,7 +511,7 @@ pub async fn execute_ban_with_marker( SET step_marker = 'mutation_committed', updated_at = now() WHERE id = $1 AND action_lease_token = $2 - AND action_lease_expires_at > now() + AND action_lease_expires_at > clock_timestamp() AND state = 'enforcing' AND step_marker IS NULL "#, @@ -559,6 +568,12 @@ pub async fn execute_timeout_with_marker( return Ok(false); } + // Acquire the action-row lock before evaluating the wall-clock expiry check. + sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") + .bind(action_id) + .fetch_optional(&mut *tx) + .await?; + sqlx::query( r#" INSERT INTO community_bans (community_id, pubkey, banned, muted_until, actor_pubkey, mute_reason) @@ -584,7 +599,7 @@ pub async fn execute_timeout_with_marker( SET step_marker = 'mutation_committed', updated_at = now() WHERE id = $1 AND action_lease_token = $2 - AND action_lease_expires_at > now() + AND action_lease_expires_at > clock_timestamp() AND state = 'enforcing' AND step_marker IS NULL "#, @@ -655,6 +670,12 @@ pub async fn execute_kick_with_marker( return Ok(KickWithMarkerResult::AlreadyMarked); } + // Acquire the action-row lock before evaluating the wall-clock expiry check. + sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") + .bind(action_id) + .fetch_optional(&mut *tx) + .await?; + let kick = sqlx::query( r#" UPDATE channel_members @@ -680,7 +701,7 @@ pub async fn execute_kick_with_marker( SET step_marker = 'mutation_committed', updated_at = now() WHERE id = $1 AND action_lease_token = $2 - AND action_lease_expires_at > now() + AND action_lease_expires_at > clock_timestamp() AND state = 'enforcing' AND step_marker IS NULL "#, @@ -712,7 +733,7 @@ pub async fn execute_delete_with_marker( community_id: CommunityId, target_event_id: &[u8], parent_event_id: Option<&[u8]>, - _root_event_id: Option<&[u8]>, + root_event_id: Option<&[u8]>, ) -> Result { let mut tx = pool.begin().await?; @@ -737,33 +758,23 @@ pub async fn execute_delete_with_marker( return Ok(false); } - // Soft-delete the event and update thread metadata (idempotent: already-deleted is a no-op). - sqlx::query( - r#" - UPDATE events - SET deleted_at = now() - WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL - "#, - ) - .bind(community_id.as_uuid()) - .bind(target_event_id) - .execute(&mut *tx) - .await?; - - // Update thread metadata if parent is known. - if let Some(parent) = parent_event_id { - sqlx::query( - r#" - UPDATE events - SET reply_count = GREATEST(reply_count - 1, 0) - WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL - "#, - ) - .bind(community_id.as_uuid()) - .bind(parent) - .execute(&mut *tx) + // Acquire the action-row lock before evaluating the wall-clock expiry check. + sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") + .bind(action_id) + .fetch_optional(&mut *tx) .await?; - } + + // Canonical delete + thread_metadata counters, fenced by this transaction. + // Counters move only when the row transitions to deleted, so a second + // action against an already-deleted target leaves them unchanged. + crate::event::soft_delete_event_and_update_thread_in_tx( + &mut tx, + community_id, + target_event_id, + parent_event_id, + root_event_id, + ) + .await?; let marker = sqlx::query( r#" @@ -771,7 +782,7 @@ pub async fn execute_delete_with_marker( SET step_marker = 'mutation_committed', updated_at = now() WHERE id = $1 AND action_lease_token = $2 - AND action_lease_expires_at > now() + AND action_lease_expires_at > clock_timestamp() AND state = 'enforcing' AND step_marker IS NULL "#, @@ -1027,21 +1038,58 @@ pub async fn record_failure( lease_token: Uuid, error: &str, ) -> Result { + let mut tx = pool.begin().await?; + + // Acquire the action-row lock first so the wall-clock expiry check below + // is evaluated under the lock, not before it. Without this, Postgres can + // test clock_timestamp() before waiting on a locked row, allowing an + // expired writer to record failure after its lease ended. + let row = sqlx::query_scalar::<_, bool>( + r#" + SELECT action_lease_expires_at > clock_timestamp() + FROM relay_admin_actions + WHERE id = $1 + AND action_lease_token = $2 + AND state = 'enforcing' + AND step_marker IS NULL + FOR UPDATE + "#, + ) + .bind(action_id) + .bind(lease_token) + .fetch_optional(&mut *tx) + .await?; + + match row { + Some(true) => {} + _ => { + tx.rollback().await?; + return Ok(false); + } + } + let result = sqlx::query( r#" UPDATE relay_admin_actions SET state = 'failed', error_message = $2, updated_at = now() WHERE id = $1 AND state = 'enforcing' AND step_marker IS NULL AND action_lease_token = $3 - AND action_lease_expires_at > now() + AND action_lease_expires_at > clock_timestamp() "#, ) .bind(action_id) .bind(error) .bind(lease_token) - .execute(pool) + .execute(&mut *tx) .await?; - Ok(result.rows_affected() > 0) + + if result.rows_affected() == 0 { + tx.rollback().await?; + return Ok(false); + } + + tx.commit().await?; + Ok(true) } /// Cancel a failed action (pre-mutation only) and return its report to 'open'. @@ -2920,6 +2968,536 @@ mod postgres_tests { ); } + // ── execute_delete_with_marker: thread counters ───────────────────────── + + /// Root ← reply ← nested; returns (root, reply, nested) event ids. + async fn make_thread(pool: &PgPool, community: CommunityId) -> [Vec; 3] { + use crate::channel::{ChannelType, ChannelVisibility}; + use crate::event::{insert_event_with_thread_metadata, ThreadMetadataParams}; + use nostr::{EventBuilder, Keys, Kind}; + + let keys = Keys::generate(); + let channel = Uuid::new_v4(); + crate::channel::create_channel_with_id( + pool, + community, + channel, + &format!("admin-delete-{channel}"), + ChannelType::Stream, + ChannelVisibility::Open, + None, + keys.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("channel"); + let events: Vec = ["root", "reply", "nested"] + .iter() + .map(|c| { + EventBuilder::new(Kind::Custom(9), *c) + .sign_with_keys(&keys) + .expect("sign") + }) + .collect(); + let at = |e: &nostr::Event| { + DateTime::from_timestamp(e.created_at.as_secs() as i64, 0).expect("ts") + }; + let (root, reply, nested) = (&events[0], &events[1], &events[2]); + for (event, parent, depth) in [ + (root, None, 0), + (reply, Some(root), 1), + (nested, Some(reply), 2), + ] { + insert_event_with_thread_metadata( + pool, + community, + event, + Some(channel), + Some(ThreadMetadataParams { + event_id: event.id.as_bytes(), + event_created_at: at(event), + channel_id: channel, + parent_event_id: parent.map(|p| p.id.as_bytes().as_slice()), + parent_event_created_at: parent.map(at), + root_event_id: parent.map(|_| root.id.as_bytes().as_slice()), + root_event_created_at: parent.map(|_| at(root)), + depth, + broadcast: false, + }), + ) + .await + .expect("thread event"); + } + [root, reply, nested].map(|e| e.id.as_bytes().to_vec()) + } + + /// Claim + enforce a fresh delete action; returns (action_id, lease_token). + async fn enforcing_action( + pool: &PgPool, + community_id: Uuid, + lease_until: DateTime, + ) -> (Uuid, Uuid) { + let report_id = make_report(pool, community_id).await; + let action_id = match do_claim(pool, community_id, report_id, Uuid::new_v4()).await { + ClaimResult::Claimed(a) => a.id, + other => panic!("expected Claimed, got {other:?}"), + }; + begin_enforcing(pool, action_id) + .await + .expect("begin_enforcing"); + match acquire_action_lease(pool, action_id, lease_until) + .await + .expect("acquire lease") + { + LeaseResult::Acquired(t) => (action_id, t), + other => panic!("expected Acquired, got {other:?}"), + } + } + + /// (reply_count, descendant_count) as the thread summary reader sees them. + async fn counts(pool: &PgPool, community: CommunityId, id: &[u8]) -> (i32, i32) { + let s = crate::thread::get_thread_summary(pool, community, id) + .await + .expect("summary") + .expect("thread row"); + (s.reply_count, s.descendant_count) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn admin_delete_updates_thread_summary_once_across_actions() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let cid = CommunityId::from_uuid(community_id); + let [root, reply, nested] = make_thread(&pool, cid).await; + assert_eq!(counts(&pool, cid, &reply).await, (1, 0)); + assert_eq!(counts(&pool, cid, &root).await, (1, 2)); + + let lease_until = Utc::now() + chrono::Duration::seconds(60); + for _ in 0..2 { + // Two distinct actions against the same target: only the first + // transitions the row, so counters move exactly once. + let (action_id, token) = enforcing_action(&pool, community_id, lease_until).await; + let committed = execute_delete_with_marker( + &pool, + action_id, + token, + cid, + &nested, + Some(&reply), + Some(&root), + ) + .await + .expect("delete"); + assert!(committed, "each action commits its own marker"); + assert_eq!( + counts(&pool, cid, &reply).await, + (0, 0), + "parent reply_count" + ); + assert_eq!( + counts(&pool, cid, &root).await, + (1, 1), + "root descendant_count" + ); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn admin_delete_with_already_expired_lease_changes_nothing() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let cid = CommunityId::from_uuid(community_id); + let [root, reply, nested] = make_thread(&pool, cid).await; + + let expired = Utc::now() - chrono::Duration::seconds(1); + let (action_id, token) = enforcing_action(&pool, community_id, expired).await; + let committed = execute_delete_with_marker( + &pool, + action_id, + token, + cid, + &nested, + Some(&reply), + Some(&root), + ) + .await + .expect("delete"); + assert!(!committed, "expired lease must not commit"); + + let deleted: bool = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events WHERE community_id = $1 AND id = $2", + ) + .bind(community_id) + .bind(&nested) + .fetch_one(&pool) + .await + .expect("event row"); + assert!(!deleted, "delete must roll back with the fence"); + assert_eq!(counts(&pool, cid, &reply).await, (1, 0)); + assert_eq!(counts(&pool, cid, &root).await, (1, 2)); + } + + /// Regression: lease live at transaction entry, expires while the event write blocks. + /// The target event row is locked externally; the worker blocks at the `UPDATE events` + /// domain write (after it has already acquired the action-row lock). The test confirms + /// the worker is observably blocked while the lease is live, waits for DB-clock expiry, + /// releases the event row unchanged, and asserts the worker rejects. + /// + /// This proves the final wall-clock CAS fires after the domain write, which is a + /// different wait point than the action-row test below. A slow setup fails the + /// observation precondition rather than passing through early rejection. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn admin_delete_cluster_global_lease_expiring_during_write_changes_nothing() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let cid = CommunityId::from_uuid(community_id); + let [root, reply, nested] = make_thread(&pool, cid).await; + + // 3-second lease: enough headroom for setup; will be expired before unlock. + let lease_until = Utc::now() + chrono::Duration::seconds(3); + let (action_id, token) = enforcing_action(&pool, community_id, lease_until).await; + + // Lock the target event row without changing it. The worker acquires the + // action-row lock first (pre-entry + FOR UPDATE), then blocks here at the + // UPDATE events domain write before reaching the final marker CAS. + let mut locker = pool.begin().await.expect("locker tx"); + let locker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *locker) + .await + .expect("locker pid"); + sqlx::query("SELECT id FROM events WHERE community_id = $1 AND id = $2 FOR UPDATE") + .bind(community_id) + .bind(&nested) + .fetch_one(&mut *locker) + .await + .expect("lock event row"); + + let worker_pool = pool.clone(); + let nr = nested.clone(); + let pr = reply.clone(); + let rr = root.clone(); + let worker = tokio::spawn(async move { + execute_delete_with_marker( + &worker_pool, + action_id, + token, + cid, + &nr, + Some(&pr), + Some(&rr), + ) + .await + }); + + // Wait until the worker is blocked on the event-row lock (domain write). + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM pg_stat_activity \ + WHERE datname = current_database() \ + AND wait_event_type = 'Lock' \ + AND (query LIKE '%UPDATE events%' OR query LIKE '%events%FOR UPDATE%') \ + AND $1 = ANY(pg_blocking_pids(pid))\ + )", + ) + .bind(locker_pid) + .fetch_one(&pool) + .await + .expect("observe worker blocked at event write"); + if blocked { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("worker must block on the event-row write within 10 s"); + + // Precondition: lease must still be live while the worker is blocked at the + // domain write. Slow setup fails here rather than passing through early rejection. + let live_when_blocked: bool = sqlx::query_scalar( + "SELECT action_lease_expires_at > clock_timestamp() FROM relay_admin_actions WHERE id = $1", + ) + .bind(action_id) + .fetch_one(&pool) + .await + .expect("live_when_blocked"); + assert!( + live_when_blocked, + "lease must be live when worker is blocked at the event write — precondition failed, not a fence failure" + ); + + // Wait until the lease is definitely expired according to the DB clock. + sqlx::query( + "SELECT pg_sleep(GREATEST(EXTRACT(EPOCH FROM action_lease_expires_at - clock_timestamp()), 0)::double precision + 0.05) \ + FROM relay_admin_actions WHERE id = $1", + ) + .bind(action_id) + .execute(&pool) + .await + .expect("wait through expiry"); + + let expired: bool = sqlx::query_scalar( + "SELECT action_lease_expires_at <= clock_timestamp() FROM relay_admin_actions WHERE id = $1", + ) + .bind(action_id) + .fetch_one(&pool) + .await + .expect("expired_before_unlock"); + assert!( + expired, + "lease must be expired before releasing the event-row lock" + ); + + // Release the event row without writing a new version. + locker.rollback().await.expect("release event-row lock"); + + let committed = worker.await.expect("worker join").expect("worker result"); + + let deleted: bool = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events WHERE community_id = $1 AND id = $2", + ) + .bind(community_id) + .bind(&nested) + .fetch_one(&pool) + .await + .expect("event row"); + assert!(!committed, "lease expired mid-write must not commit"); + assert!( + !deleted, + "event must not be deleted when lease expires mid-write" + ); + let stored_marker = get_action(&pool, action_id) + .await + .expect("action lookup") + .expect("action exists") + .step_marker; + assert_eq!(stored_marker, None, "step_marker must not be persisted"); + assert_eq!( + counts(&pool, cid, &reply).await, + (1, 0), + "parent counts must be unchanged" + ); + assert_eq!( + counts(&pool, cid, &root).await, + (1, 2), + "root counts must be unchanged" + ); + } + + /// Regression for the row-lock wait gap: Postgres can evaluate + /// `clock_timestamp()` before waiting to lock the action row. If another + /// transaction holds the row and releases it unchanged after the lease expires, + /// the final marker UPDATE would commit without the lock-first shape. + /// + /// This test locks the action row externally. The worker blocks at the + /// `SELECT … FOR UPDATE` that acquires the action-row lock. The test confirms + /// the worker is observably blocked while the lease is live, waits for DB-clock + /// expiry, releases the lock unchanged, and asserts the worker rejects. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn admin_delete_cluster_global_lease_expiring_during_row_lock_wait_changes_nothing() { + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let cid = CommunityId::from_uuid(community_id); + let [root, reply, nested] = make_thread(&pool, cid).await; + + // 3-second lease gives enough headroom for setup; will be expired before unlock. + let (action_id, token) = enforcing_action( + &pool, + community_id, + Utc::now() + chrono::Duration::seconds(3), + ) + .await; + + // Lock the action row without changing it; the worker will block at the + // explicit FOR UPDATE in execute_delete_with_marker. + let mut locker = pool.begin().await.expect("locker tx"); + let locker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *locker) + .await + .expect("locker pid"); + sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") + .bind(action_id) + .fetch_one(&mut *locker) + .await + .expect("lock action row"); + + let worker_pool = pool.clone(); + let nr = nested.clone(); + let pr = reply.clone(); + let rr = root.clone(); + let worker = tokio::spawn(async move { + execute_delete_with_marker( + &worker_pool, + action_id, + token, + cid, + &nr, + Some(&pr), + Some(&rr), + ) + .await + }); + + // Wait until the worker is blocked at the action-row lock. + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM pg_stat_activity \ + WHERE datname = current_database() \ + AND wait_event_type = 'Lock' \ + AND (query LIKE '%relay_admin_actions%FOR UPDATE%' OR query LIKE '%SET step_marker%') \ + AND $1 = ANY(pg_blocking_pids(pid))\ + )", + ) + .bind(locker_pid) + .fetch_one(&pool) + .await + .expect("observe worker blocked"); + if blocked { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("worker must block on the action-row FOR UPDATE within 10 s"); + + // Precondition: lease must still be live when the worker is blocked. + let live_when_blocked: bool = sqlx::query_scalar( + "SELECT action_lease_expires_at > clock_timestamp() FROM relay_admin_actions WHERE id = $1", + ) + .bind(action_id) + .fetch_one(&pool) + .await + .expect("live_when_blocked"); + assert!( + live_when_blocked, + "lease must be live when worker reaches the row lock" + ); + + // Wait until the lease is definitely expired according to the DB clock. + sqlx::query( + "SELECT pg_sleep(GREATEST(EXTRACT(EPOCH FROM action_lease_expires_at - clock_timestamp()), 0)::double precision + 0.05) \ + FROM relay_admin_actions WHERE id = $1", + ) + .bind(action_id) + .execute(&pool) + .await + .expect("wait through expiry"); + + let expired: bool = sqlx::query_scalar( + "SELECT action_lease_expires_at <= clock_timestamp() FROM relay_admin_actions WHERE id = $1", + ) + .bind(action_id) + .fetch_one(&pool) + .await + .expect("expired_before_unlock"); + assert!( + expired, + "lease must be expired before releasing the row lock" + ); + + // Release the row lock without writing a new tuple version. + locker.rollback().await.expect("release row lock"); + + let committed = worker.await.expect("worker join").expect("worker result"); + + let deleted: bool = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events WHERE community_id = $1 AND id = $2", + ) + .bind(community_id) + .bind(&nested) + .fetch_one(&pool) + .await + .expect("event row"); + let stored_marker = get_action(&pool, action_id) + .await + .expect("action lookup") + .expect("action exists") + .step_marker; + + assert!( + !committed, + "lease expired during row-lock wait must not commit" + ); + assert!(!deleted, "event must not be deleted"); + assert_eq!(stored_marker, None, "step_marker must not be persisted"); + assert_eq!( + counts(&pool, cid, &reply).await, + (1, 0), + "parent counts unchanged" + ); + assert_eq!( + counts(&pool, cid, &root).await, + (1, 2), + "root counts unchanged" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn admin_delete_rolled_back_by_marker_fence_changes_nothing() { + // Same action re-driven after its marker is set: the marker UPDATE + // matches zero rows, so the whole transaction (delete + counters) rolls back. + let pool = setup_pool().await; + let community_id = make_community(&pool).await; + let cid = CommunityId::from_uuid(community_id); + let [root, reply, nested] = make_thread(&pool, cid).await; + let lease_until = Utc::now() + chrono::Duration::seconds(60); + let (action_id, token) = enforcing_action(&pool, community_id, lease_until).await; + + assert!(execute_delete_with_marker( + &pool, + action_id, + token, + cid, + &reply, + Some(&root), + Some(&root) + ) + .await + .expect("first delete")); + let second = execute_delete_with_marker( + &pool, + action_id, + token, + cid, + &nested, + Some(&reply), + Some(&root), + ) + .await + .expect("second delete"); + assert!(!second, "marker already set must roll back"); + + let deleted: bool = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events WHERE community_id = $1 AND id = $2", + ) + .bind(community_id) + .bind(&nested) + .fetch_one(&pool) + .await + .expect("event row"); + assert!(!deleted, "rolled-back delete must not persist"); + assert_eq!( + counts(&pool, cid, &reply).await, + (1, 0), + "nested's parent untouched" + ); + assert_eq!( + counts(&pool, cid, &root).await, + (0, 1), + "only the first delete counted" + ); + } + // ── execute_kick_with_marker: provenance — Removed vs AlreadyGone ───────── #[tokio::test] diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index b64ff09c31..5b30f5df83 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -260,96 +260,6 @@ pub async fn insert_thread_metadata( Ok(()) } -/// Increment `reply_count` (and `last_reply_at`) on the parent event. -/// If `root_event_id` is provided, also increments `descendant_count` on the -/// root -- even when root == parent (direct reply to root). This is correct -/// because `reply_count` tracks direct children only, while `descendant_count` -/// tracks ALL descendants at every nesting level. -/// -/// NOTE: The primary increment path is inlined inside [`insert_thread_metadata`]'s -/// transaction. This standalone version exists for future use cases where -/// incrementing outside of insert is needed (e.g., event re-parenting). -#[allow(dead_code)] -pub async fn increment_reply_count( - pool: &PgPool, - community_id: CommunityId, - parent_event_id: &[u8], - root_event_id: Option<&[u8]>, -) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; - // Always bump the parent's direct reply count and last-reply timestamp. - sqlx::query( - r#" - UPDATE thread_metadata - SET reply_count = reply_count + 1, - last_reply_at = NOW() - WHERE community_id = $1 AND event_id = $2 - "#, - ) - .bind(community_id.as_uuid()) - .bind(parent_event_id) - .execute(&mut *connection) - .await?; - - // Always bump root's descendant_count, regardless of whether root == parent. - if let Some(root_id) = root_event_id { - sqlx::query( - r#" - UPDATE thread_metadata - SET descendant_count = descendant_count + 1 - WHERE community_id = $1 AND event_id = $2 - "#, - ) - .bind(community_id.as_uuid()) - .bind(root_id) - .execute(&mut *connection) - .await?; - } - - Ok(()) -} - -/// Decrement `reply_count` on the parent event (floor at 0). -/// If `root_event_id` is provided, also decrements `descendant_count` on the -/// root -- even when root == parent. Mirrors the increment logic exactly. -pub async fn decrement_reply_count( - pool: &PgPool, - community_id: CommunityId, - parent_event_id: &[u8], - root_event_id: Option<&[u8]>, -) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; - // Always decrement the parent's direct reply count (floor at 0). - sqlx::query( - r#" - UPDATE thread_metadata - SET reply_count = GREATEST(reply_count - 1, 0) - WHERE community_id = $1 AND event_id = $2 - "#, - ) - .bind(community_id.as_uuid()) - .bind(parent_event_id) - .execute(&mut *connection) - .await?; - - // Always decrement root's descendant_count, regardless of whether root == parent. - if let Some(root_id) = root_event_id { - sqlx::query( - r#" - UPDATE thread_metadata - SET descendant_count = GREATEST(descendant_count - 1, 0) - WHERE community_id = $1 AND event_id = $2 - "#, - ) - .bind(community_id.as_uuid()) - .bind(root_id) - .execute(&mut *connection) - .await?; - } - - Ok(()) -} - // -- Read operations ---------------------------------------------------------- /// Fetch all replies under a root event, ordered chronologically. @@ -1252,23 +1162,6 @@ impl Db { ) -> Result> { crate::thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await } - - /// Decrement reply counts. - #[datastore_span(name = "decrement_reply_count", system = "postgresql")] - pub async fn decrement_reply_count( - &self, - community_id: CommunityId, - parent_event_id: &[u8], - root_event_id: Option<&[u8]>, - ) -> Result<()> { - crate::thread::decrement_reply_count( - &self.pool, - community_id, - parent_event_id, - root_event_id, - ) - .await - } } #[cfg(test)] diff --git a/crates/buzz-feature-flags/Cargo.toml b/crates/buzz-feature-flags/Cargo.toml new file mode 100644 index 0000000000..258b54cf6d --- /dev/null +++ b/crates/buzz-feature-flags/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "buzz-feature-flags" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Typed feature-flag contracts and evaluators for Buzz" + +[features] +launchdarkly = ["dep:launchdarkly-server-sdk"] + +[dependencies] +buzz-core = { workspace = true } +http = "1" +launchdarkly-server-sdk = { version = "=3.2.0", optional = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +tempfile = "3" +tokio = { workspace = true } diff --git a/crates/buzz-feature-flags/README.md b/crates/buzz-feature-flags/README.md new file mode 100644 index 0000000000..6210701a6e --- /dev/null +++ b/crates/buzz-feature-flags/README.md @@ -0,0 +1,339 @@ +# buzz-feature-flags + +Provider-neutral, typed feature-flag contracts for Buzz server crates. + +This crate is infrastructure-only. It defines a small API for flag evaluation, +plus a static evaluator and an optional LaunchDarkly adapter behind a compile +feature. + +## Purpose + +`buzz-feature-flags` centralizes typed flag evaluation so server crates can use +flags without importing vendor SDK types. + +Provider-neutral types: + +- `BooleanFlag` +- `IntegerFlag` (`i64`, including negative values) +- `EvaluationContext` (required `CommunityId`, optional actor `PublicKey`) +- `FlagEvaluator` (typed `evaluate_bool` and `evaluate_int`) +- `StaticEvaluator` (always returns declared defaults) +- `EnvironmentEvaluator` (process-wide immutable environment snapshot) +- `EnvironmentDiagnostic` (sanitized malformed-value report) + +LaunchDarkly support is optional and compile-gated behind +`buzz-feature-flags/launchdarkly`. + +`EnvironmentEvaluator` is provider-neutral and always available (no Cargo +feature and no external dependency). + +## Environment Variable Naming + +`EnvironmentEvaluator` reads only the fixed namespace +`BUZZ_FEATURE_FLAG_`. + +For each flag key: + +- uppercase ASCII letters, +- replace each run of non-ASCII-alphanumeric characters with `_`, +- trim leading/trailing `_`, +- prepend `BUZZ_FEATURE_FLAG_`. + +Example: `relay.feature.query-v2` → `BUZZ_FEATURE_FLAG_RELAY_FEATURE_QUERY_V2`. + +Normalized names must be unique across declared flags. Collisions (for example +`a-b` and `a_b`) intentionally map to the same environment variable. Enforcing +uniqueness belongs in a future flag registry; this crate does not add a +registry yet. + +## Relay Artifact Contract (not yet wired into `buzz-relay`) + +The intended composition root is the outer relay artifact. It must select +exactly one provider at compile time: + +- `static-feature-flags` +- `environment-feature-flags` +- `launchdarkly-feature-flags` + +`launchdarkly-feature-flags` is the only relay feature that forwards to +`buzz-feature-flags/launchdarkly`. `static-feature-flags` and +`environment-feature-flags` keep the reusable crate vendor-neutral and still +allow adapter code to compile in tests. + +The relay artifact boundary, not this reusable crate, owns exact-one +enforcement. Consumer integration PRs must carry `exact-one compile_error guards` +verbatim/equivalently. That keeps `buzz-feature-flags` free to compile multiple +adapters for tests while ensuring a staging or production relay artifact cannot +choose a different evaluator at runtime. + +This repository currently adds the crate and adapter, but does **not** yet wire +relay `AppState` or handlers to consume it. `crates/buzz-relay/Cargo.toml` +still has no dependency on `buzz-feature-flags`, so current workspace/default +builds remain intentionally unchanged. The executable contract for the future +relay integration lives in +`tests/fixtures/relay-feature-selection`. + +## Composition Root Shape (artifact boundary) + +Construct once at process startup, then inject shared `Arc` +into server components. + +```rust +use std::sync::Arc; +use std::time::Duration; + +use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator, StaticEvaluator}; +#[cfg(feature = "launchdarkly-feature-flags")] +use buzz_feature_flags::launchdarkly::{ + LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig, + LaunchDarklyStartError, +}; + +#[cfg(not(any( + feature = "static-feature-flags", + feature = "environment-feature-flags", + feature = "launchdarkly-feature-flags", +)))] +compile_error!( + "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" +); + +#[cfg(any( + all(feature = "static-feature-flags", feature = "environment-feature-flags"), + all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"), + all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"), +))] +compile_error!( + "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" +); + +#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))] +#[derive(Default)] +struct FeatureFlagRuntimeConfig; + +#[cfg(feature = "launchdarkly-feature-flags")] +struct FeatureFlagRuntimeConfig { + sdk_key: String, + relay_proxy_endpoint: Option, +} + +struct RelayCompositionRoot { + feature_flags: Arc, + environment_diagnostics: Option>, + #[cfg(feature = "launchdarkly-feature-flags")] + launchdarkly_lifecycle: Option>, +} + +#[cfg(feature = "launchdarkly-feature-flags")] +enum RelayStartupError { + LaunchDarklyInit(LaunchDarklyInitError), + LaunchDarklyStart(LaunchDarklyStartError), +} + +#[cfg(feature = "static-feature-flags")] +fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot { + RelayCompositionRoot { + feature_flags: Arc::new(StaticEvaluator), + environment_diagnostics: None, + } +} + +#[cfg(feature = "environment-feature-flags")] +fn build_feature_flag_evaluator(config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot { + let _ = config; + let owner = Arc::new(EnvironmentEvaluator::from_process_environment()); + let feature_flags: Arc = owner.clone(); + RelayCompositionRoot { + feature_flags, + environment_diagnostics: Some(owner), + } +} + +#[cfg(feature = "launchdarkly-feature-flags")] +async fn build_feature_flag_evaluator( + config: FeatureFlagRuntimeConfig, +) -> Result { + let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key); + if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint { + runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint); + } + + let owner = Arc::new( + LaunchDarklyEvaluator::from_runtime_config(runtime_config) + .map_err(RelayStartupError::LaunchDarklyInit)?, + ); + owner + .start_with_default_executor_and_wait(Duration::from_secs(5)) + .await + .map_err(RelayStartupError::LaunchDarklyStart)?; + + let feature_flags: Arc = owner.clone(); + Ok(RelayCompositionRoot { + feature_flags, + environment_diagnostics: None, + launchdarkly_lifecycle: Some(owner), + }) +} +``` + +Each relay feature produces exactly one `build_feature_flag_evaluator` path, and +runtime config carries only the selected provider's settings. There is no +runtime provider enum, no provider-name environment variable, and no fallback +selector. + +Startup is fail-closed: if the selected provider fails initialization (including +`start_with_default_executor_and_wait` for LaunchDarkly), relay startup must +return an error and stop rather than falling back to another evaluator. + +The extra concrete `Arc` is an owner/lifecycle handle to that same evaluator, +not a layered provider. Consumers receive only the cloned `Arc` view. Retain the environment owner to drain diagnostics after +evaluations, and retain the LaunchDarkly owner so shutdown can call `close()`. + +`EnvironmentEvaluator` snapshots environment values once at construction, +retains only `BUZZ_FEATURE_FLAG_` entries, ignores `EvaluationContext`, and +never rereads process environment during evaluation. + +For deterministic tests or embedding, `EnvironmentEvaluator::from_pairs(...)` +constructs the same immutable snapshot from explicit key/value pairs. + +Environment entries are untyped at construction, so malformed present values +are detected when a typed flag is first evaluated. `take_diagnostics()` drains +each sanitized diagnostic at most once per flag key and expected type. The +composition root should retain the concrete environment evaluator and emit each +drained diagnostic as a warning through the application's logging stack. +Diagnostics contain no raw value or environment-variable name; the value +snapshot itself remains immutable. + +## `buzz-db` Boundary (approved correction) + +`buzz-db` may evaluate flags internally when choosing between equivalent query +implementations. It should depend on `buzz-feature-flags` without enabling the +LaunchDarkly feature, and it should never construct or import provider types. + +Concise example (query path selection only): + +```rust +use buzz_core::CommunityId; +use std::sync::Arc; + +use buzz_feature_flags::{EvaluationContext, FlagEvaluator, IntegerFlag}; + +pub struct Db { + feature_flags: Arc, +} + +impl Db { + pub async fn list_events( + &self, + community: CommunityId, + ) -> anyhow::Result> { + let context = EvaluationContext::for_community(community); + + let query_version = self.feature_flags.evaluate_int( + IntegerFlag::new("db.events.query-version", 1), + &context, + ); + + if query_version >= 2 { + self.list_events_v2_sql(community).await + } else { + self.list_events_v1_sql(community).await + } + } +} +``` + +SQL details are intentionally omitted in this example; the key point is that +public `Db` methods accept only domain inputs, while feature-flag evaluator +wiring remains internal to `Db` construction. + +`EvaluationContext::for_actor` is appropriate only when the existing DB +operation already receives an authoritative authenticated actor for domain +behavior. Feature targeting must not add actor/pubkey parameters to otherwise +actor-free DB APIs. + +For LaunchDarkly, the adapter context always includes kind `community` plus +optional global kind `pubkey`. Percentage rollouts must explicitly choose a +kind present in evaluation: rollouts must set `contextKind` to a kind present +in evaluation (`community` or `pubkey`) when they are intended to bucket Buzz +traffic. LaunchDarkly rollouts where configuration omits `contextKind` defaults +to `user`; Buzz does not synthesize a `user` alias, so those rollouts bucket as +zero and pick the first positive-weight variation. + +## Guardrails + +- Evaluation context inputs are authoritative server-resolved values + (`community`, optional `actor`), not client-supplied targeting attributes. +- Flags may choose between **equivalent** implementations only. +- Flags must not weaken authorization, tenant isolation, ordering guarantees, + transaction/consistency behavior, or schema invariants. +- Declared defaults choose the established safe path. +- Integer values must be validated at the consumer boundary before affecting + behavior. + +## Fallback, Startup, and Lifecycle + +- `StaticEvaluator` always returns each flag's declared default. +- `EnvironmentEvaluator` returns declared defaults when values are missing, + empty, invalid, non-Unicode, or the normalized flag key is unusable. Present + malformed values also produce a sanitized diagnostic; missing values do not. +- LaunchDarkly adapter returns declared defaults when a flag is missing, wrong + type, or evaluation fails. +- LaunchDarkly numbers are SDK `f64` values. Integer evaluation accepts only + mathematically integral values that convert to `i64` exactly from that `f64` + representation; otherwise it returns each flag's declared default. +- Startup chooses one evaluator at compile time (`StaticEvaluator`, + `EnvironmentEvaluator`, or LaunchDarkly); provider precedence/stacking is out + of scope. +- If LaunchDarkly is used, call evaluator `close()` during process shutdown. + `close()` blocks the calling thread while it flushes analytics, so async + shutdown code should offload it to a blocking shutdown path. +- Safety invariants must never rely on remote-flag availability. + +## Build and Test Expectations + +Build modes: + +```bash +# Provider-neutral (default): no LaunchDarkly dependency activated +cargo build -p buzz-feature-flags + +# Provider-neutral environment evaluator is included in the default build +cargo test -p buzz-feature-flags --test environment_evaluator + +# LaunchDarkly adapter enabled +cargo build -p buzz-feature-flags --features launchdarkly +``` + +Dependency-graph expectation checks: + +```bash +# Default graph should exclude launchdarkly-server-sdk +cargo tree -p buzz-feature-flags + +# Feature graph should include launchdarkly-server-sdk +cargo tree -p buzz-feature-flags --features launchdarkly +``` + +Testing expectations: + +- Run provider-neutral tests and LaunchDarkly-feature tests for this crate. +- Run the relay-artifact compile contract in + `tests/relay_feature_selection_contract.rs`, including zero-feature and + multiple-feature rejection. +- Keep `tests/fixtures/relay-feature-selection/Cargo.lock` checked in for the + nested `--locked` compile contract. Regenerate it from repo root with: + +```bash +cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml +``` + +- When `buzz-db` adopts flag-gated query selection, add parity tests proving + old/new query paths return equivalent rows, ordering, and transactional + behavior for the same inputs. + +## References + +- [ARCHITECTURE.md](../../ARCHITECTURE.md) +- [docs/multi-tenant-relay.md](../../docs/multi-tenant-relay.md) diff --git a/crates/buzz-feature-flags/src/environment.rs b/crates/buzz-feature-flags/src/environment.rs new file mode 100644 index 0000000000..ef00e9c487 --- /dev/null +++ b/crates/buzz-feature-flags/src/environment.rs @@ -0,0 +1,321 @@ +//! Process-wide environment-backed feature-flag evaluator. +//! +//! Environment variables use the fixed `BUZZ_FEATURE_FLAG_` namespace and a +//! normalized form of each flag key. +//! +//! Normalization algorithm: +//! - Uppercase ASCII letters. +//! - Replace each run of non-ASCII-alphanumeric characters with `_`. +//! - Trim leading and trailing `_`. +//! +//! If normalization yields an empty key segment, evaluation falls back to the +//! flag-declared default. +//! +//! Different flag keys can normalize to the same environment variable name +//! (for example `a-b` and `a_b`), so callers must keep normalized names unique. + +use std::{ + collections::{BTreeSet, HashMap}, + ffi::OsString, + fmt::{Debug, Formatter}, + sync::{Arc, Mutex}, +}; + +use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag}; + +const ENV_NAMESPACE: &str = "BUZZ_FEATURE_FLAG_"; + +#[derive(Clone)] +enum SnapshotValue { + Unicode(String), + NonUnicode, +} + +/// Sanitized diagnostic produced for a malformed configured environment value. +/// +/// Diagnostics identify only the provider-neutral flag key and expected type. +/// They never contain raw values or environment-variable names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum EnvironmentDiagnostic { + /// A present value could not be evaluated as a boolean. + InvalidBooleanValue { + /// Stable provider-neutral flag key. + flag_key: &'static str, + }, + /// A present value could not be evaluated as a signed integer. + InvalidIntegerValue { + /// Stable provider-neutral flag key. + flag_key: &'static str, + }, +} + +#[derive(Default)] +struct DiagnosticState { + seen: BTreeSet, + pending: BTreeSet, +} + +/// Process-wide feature-flag evaluator backed by an immutable environment +/// snapshot. +/// +/// This evaluator captures key/value pairs once at construction and never +/// rereads or mutates process environment state afterward. Evaluation ignores +/// [`EvaluationContext`] because all values are process-wide. +#[derive(Clone)] +pub struct EnvironmentEvaluator { + snapshot: HashMap, + diagnostics: Arc>, +} + +impl Default for EnvironmentEvaluator { + fn default() -> Self { + Self::from_process_environment() + } +} + +impl EnvironmentEvaluator { + /// Capture a one-time snapshot of the current process environment. + /// + /// Only namespaced entries are retained. Entries with non-Unicode names are + /// ignored; non-Unicode values are retained only as invalid-value markers. + pub fn from_process_environment() -> Self { + Self::from_pairs(std::env::vars_os()) + } + + /// Build an evaluator from a deterministic key/value snapshot. + /// + /// Only entries whose names start with `BUZZ_FEATURE_FLAG_` are retained. + /// Entries with non-Unicode names are ignored; non-Unicode values are + /// retained only as invalid-value markers. + pub fn from_pairs(pairs: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + let mut snapshot = HashMap::new(); + for (name, value) in pairs { + let name = name.into(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with(ENV_NAMESPACE) { + continue; + } + + let value = match value.into().into_string() { + Ok(value) => SnapshotValue::Unicode(value), + Err(_) => SnapshotValue::NonUnicode, + }; + snapshot.insert(name.to_owned(), value); + } + + Self { + snapshot, + diagnostics: Arc::new(Mutex::new(DiagnosticState::default())), + } + } + + /// Drain sanitized diagnostics that have not previously been reported. + /// + /// Each malformed value is returned at most once per flag key and expected + /// type, including across cloned evaluators. Missing values do not produce + /// diagnostics. + pub fn take_diagnostics(&self) -> Vec { + let mut state = match self.diagnostics.lock() { + Ok(state) => state, + Err(poisoned) => poisoned.into_inner(), + }; + std::mem::take(&mut state.pending).into_iter().collect() + } + + fn lookup(&self, flag_key: &str) -> Option<&SnapshotValue> { + let env_name = normalize_flag_env_name(flag_key)?; + self.snapshot.get(&env_name) + } + + fn record_diagnostic(&self, diagnostic: EnvironmentDiagnostic) { + let mut state = match self.diagnostics.lock() { + Ok(state) => state, + Err(poisoned) => poisoned.into_inner(), + }; + if state.seen.insert(diagnostic) { + state.pending.insert(diagnostic); + } + } +} + +impl Debug for EnvironmentEvaluator { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvironmentEvaluator") + .field("namespace", &ENV_NAMESPACE) + .field("entry_count", &self.snapshot.len()) + .field("entries", &"[REDACTED]") + .finish() + } +} + +impl FlagEvaluator for EnvironmentEvaluator { + fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool { + let Some(value) = self.lookup(flag.key()) else { + return flag.default(); + }; + if let SnapshotValue::Unicode(value) = value { + if let Some(value) = parse_bool(value) { + return value; + } + } + + self.record_diagnostic(EnvironmentDiagnostic::InvalidBooleanValue { + flag_key: flag.key(), + }); + flag.default() + } + + fn evaluate_int(&self, flag: IntegerFlag, _context: &EvaluationContext) -> i64 { + let Some(value) = self.lookup(flag.key()) else { + return flag.default(); + }; + if let SnapshotValue::Unicode(value) = value { + if let Some(value) = parse_i64(value) { + return value; + } + } + + self.record_diagnostic(EnvironmentDiagnostic::InvalidIntegerValue { + flag_key: flag.key(), + }); + flag.default() + } +} + +fn parse_bool(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.eq_ignore_ascii_case("true") { + Some(true) + } else if trimmed.eq_ignore_ascii_case("false") { + Some(false) + } else { + None + } +} + +fn parse_i64(raw: &str) -> Option { + raw.trim().parse::().ok() +} + +fn normalize_flag_env_name(flag_key: &str) -> Option { + let mut normalized = String::with_capacity(flag_key.len()); + let mut in_separator = false; + + for character in flag_key.chars() { + if character.is_ascii_alphanumeric() { + normalized.push(character.to_ascii_uppercase()); + in_separator = false; + } else if !in_separator { + normalized.push('_'); + in_separator = true; + } + } + + let trimmed = normalized.trim_matches('_'); + if trimmed.is_empty() { + return None; + } + + Some(format!("{ENV_NAMESPACE}{trimmed}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::{BooleanFlag, EvaluationContext}; + use buzz_core::CommunityId; + + fn community(id: &str) -> CommunityId { + CommunityId::from_uuid(id.parse().expect("valid community UUID")) + } + + #[test] + fn normalization_uses_fixed_namespace_and_collapses_separators() { + assert_eq!( + normalize_flag_env_name("relay.feature.query-v2"), + Some("BUZZ_FEATURE_FLAG_RELAY_FEATURE_QUERY_V2".to_owned()) + ); + assert_eq!( + normalize_flag_env_name("___relay..foo---bar***baz??9___"), + Some("BUZZ_FEATURE_FLAG_RELAY_FOO_BAR_BAZ_9".to_owned()) + ); + } + + #[test] + fn normalization_rejects_empty_segment() { + assert_eq!(normalize_flag_env_name(""), None); + assert_eq!(normalize_flag_env_name("---***___"), None); + } + + #[test] + fn snapshot_retains_only_namespaced_entries() { + let evaluator = EnvironmentEvaluator::from_pairs([ + ("BUZZ_FEATURE_FLAG_RELAY_ENABLED", "true"), + ("DATABASE_URL", "sensitive"), + ]); + + assert_eq!(evaluator.snapshot.len(), 1); + assert!(evaluator + .snapshot + .contains_key("BUZZ_FEATURE_FLAG_RELAY_ENABLED")); + } + + #[cfg(unix)] + #[test] + fn non_unicode_names_are_ignored_and_values_become_invalid_markers() { + use std::os::unix::ffi::OsStringExt; + + let evaluator = EnvironmentEvaluator::from_pairs([ + ( + OsString::from("BUZZ_FEATURE_FLAG_RELAY_NON_UNICODE_NAME"), + OsString::from_vec(vec![0x80]), + ), + (OsString::from_vec(vec![0x81]), OsString::from("true")), + ]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!( + evaluator.evaluate_bool(BooleanFlag::new("relay.non-unicode-name", true), &context,) + ); + assert_eq!( + evaluator.take_diagnostics(), + vec![EnvironmentDiagnostic::InvalidBooleanValue { + flag_key: "relay.non-unicode-name", + }] + ); + } + + #[test] + fn poisoned_diagnostic_mutex_recovers_without_panicking() { + let evaluator = EnvironmentEvaluator::from_pairs([( + "BUZZ_FEATURE_FLAG_RELAY_POISONED", + "not-a-boolean", + )]); + let diagnostics = Arc::clone(&evaluator.diagnostics); + let poison_result = std::thread::spawn(move || { + let _guard = diagnostics.lock().expect("diagnostic mutex lock"); + panic!("poison diagnostic mutex"); + }) + .join(); + assert!(poison_result.is_err()); + + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.poisoned", true), &context)); + assert_eq!( + evaluator.take_diagnostics(), + vec![EnvironmentDiagnostic::InvalidBooleanValue { + flag_key: "relay.poisoned", + }] + ); + } +} diff --git a/crates/buzz-feature-flags/src/flags.rs b/crates/buzz-feature-flags/src/flags.rs new file mode 100644 index 0000000000..054e9a2abd --- /dev/null +++ b/crates/buzz-feature-flags/src/flags.rs @@ -0,0 +1,49 @@ +//! Typed feature-flag keys and declared defaults. + +/// Typed boolean feature definition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BooleanFlag { + key: &'static str, + default: bool, +} + +impl BooleanFlag { + /// Construct a typed boolean flag from its stable key and declared default. + pub const fn new(key: &'static str, default: bool) -> Self { + Self { key, default } + } + + /// Stable key for this flag. + pub const fn key(self) -> &'static str { + self.key + } + + /// Declared default used when an evaluator is unavailable or cannot return a value. + pub const fn default(self) -> bool { + self.default + } +} + +/// Typed integer feature definition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IntegerFlag { + key: &'static str, + default: i64, +} + +impl IntegerFlag { + /// Construct a typed integer flag from its stable key and declared default. + pub const fn new(key: &'static str, default: i64) -> Self { + Self { key, default } + } + + /// Stable key for this flag. + pub const fn key(self) -> &'static str { + self.key + } + + /// Declared default used when an evaluator is unavailable or cannot return a value. + pub const fn default(self) -> i64 { + self.default + } +} diff --git a/crates/buzz-feature-flags/src/launchdarkly.rs b/crates/buzz-feature-flags/src/launchdarkly.rs new file mode 100644 index 0000000000..f5ce4a33c8 --- /dev/null +++ b/crates/buzz-feature-flags/src/launchdarkly.rs @@ -0,0 +1,953 @@ +use http::{uri::Authority, Uri}; +use launchdarkly_server_sdk::{ + BuildError, Client, ConfigBuildError, ConfigBuilder, Context, ContextBuilder, + MultiContextBuilder, +}; +use std::time::Duration; +use thiserror::Error; + +use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag}; + +const COMMUNITY_CONTEXT_KIND: &str = "community"; +const PUBKEY_CONTEXT_KIND: &str = "pubkey"; + +/// Runtime inputs required to initialize a LaunchDarkly-backed evaluator. +#[derive(Clone)] +pub struct LaunchDarklyRuntimeConfig { + /// SDK key used to authenticate with LaunchDarkly. + pub sdk_key: String, + /// Optional Relay Proxy endpoint used for all LaunchDarkly service traffic. + pub relay_proxy_endpoint: Option, +} + +impl std::fmt::Debug for LaunchDarklyRuntimeConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LaunchDarklyRuntimeConfig") + .field("sdk_key", &"[REDACTED]") + .field("relay_proxy_endpoint", &self.relay_proxy_endpoint) + .finish() + } +} + +impl LaunchDarklyRuntimeConfig { + /// Create a runtime config from explicit caller-provided values. + pub fn new(sdk_key: impl Into) -> Self { + Self { + sdk_key: sdk_key.into(), + relay_proxy_endpoint: None, + } + } + + /// Set the optional Relay Proxy endpoint. + pub fn with_relay_proxy_endpoint(mut self, endpoint: impl Into) -> Self { + self.relay_proxy_endpoint = Some(endpoint.into()); + self + } +} + +/// Errors building a LaunchDarkly evaluator from runtime inputs. +#[derive(Debug, Error)] +pub enum LaunchDarklyInitError { + /// Caller provided an empty SDK key. + #[error("launchdarkly sdk key must not be empty")] + EmptySdkKey, + /// Caller provided an empty relay-proxy endpoint. + #[error("launchdarkly relay proxy endpoint must not be empty")] + EmptyRelayProxyEndpoint, + /// Caller provided an invalid relay-proxy endpoint. + #[error("launchdarkly relay proxy endpoint must be an absolute http(s) URI with authority")] + InvalidRelayProxyEndpoint(InvalidRelayProxyEndpointReason), + /// LaunchDarkly SDK config build failed. + #[error("launchdarkly config build failed: {0}")] + ConfigBuild(#[from] ConfigBuildError), + /// LaunchDarkly SDK client build failed. + #[error("launchdarkly client build failed: {0}")] + ClientBuild(#[from] BuildError), +} + +/// Why a relay-proxy endpoint failed pre-validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvalidRelayProxyEndpointReason { + /// Endpoint could not be parsed as a URI. + MalformedUri, + /// Endpoint scheme is not `http` or `https`. + InvalidScheme, + /// Endpoint has no authority section (`host[:port]`). + MissingAuthority, +} + +/// Errors starting a LaunchDarkly evaluator and waiting for initialization. +#[derive(Debug, Error)] +pub enum LaunchDarklyStartError { + /// LaunchDarkly client did not initialize before timeout. + #[error("launchdarkly initialization timed out after {0:?}")] + InitializationTimeout(Duration), + /// LaunchDarkly client reported failed initialization. + #[error("launchdarkly initialization failed")] + InitializationFailed, +} + +/// Feature-flag evaluator backed by the LaunchDarkly Rust server SDK. +pub struct LaunchDarklyEvaluator { + client: Client, +} + +impl std::fmt::Debug for LaunchDarklyEvaluator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LaunchDarklyEvaluator").finish() + } +} + +impl LaunchDarklyEvaluator { + /// Wrap an already-constructed LaunchDarkly client. + pub(crate) fn new(client: Client) -> Self { + Self { client } + } + + /// Build a LaunchDarkly client from explicit runtime configuration. + pub fn from_runtime_config( + config: LaunchDarklyRuntimeConfig, + ) -> Result { + let sdk_key = config.sdk_key.trim(); + if sdk_key.is_empty() { + return Err(LaunchDarklyInitError::EmptySdkKey); + } + + let mut builder = ConfigBuilder::new(sdk_key); + if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint { + if relay_proxy_endpoint.trim().is_empty() { + return Err(LaunchDarklyInitError::EmptyRelayProxyEndpoint); + } + let relay_proxy_endpoint = validate_relay_proxy_endpoint(&relay_proxy_endpoint)?; + let mut service_endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new(); + service_endpoints.relay_proxy(&relay_proxy_endpoint); + builder = builder.service_endpoints(&service_endpoints); + } + + let config = builder.build()?; + let client = Client::build(config)?; + Ok(Self::new(client)) + } + + /// Start background tasks and wait for SDK initialization. + /// + /// This method must run inside a Tokio runtime. + pub async fn start_with_default_executor_and_wait( + &self, + timeout: Duration, + ) -> Result<(), LaunchDarklyStartError> { + self.client.start_with_default_executor(); + + match self.client.wait_for_initialization(timeout).await { + Some(true) => Ok(()), + Some(false) => Err(LaunchDarklyStartError::InitializationFailed), + None => Err(LaunchDarklyStartError::InitializationTimeout(timeout)), + } + } + + /// Gracefully stop background tasks and flush pending analytics events. + /// + /// This call blocks the current thread until pending analytics delivery + /// completes. Async callers should run it in a blocking shutdown path. + pub fn close(&self) { + self.client.close(); + } +} + +impl FlagEvaluator for LaunchDarklyEvaluator { + fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool { + let context = match launchdarkly_context(context) { + Ok(context) => context, + Err(_) => return flag.default(), + }; + self.client + .bool_variation(&context, flag.key(), flag.default()) + } + + fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { + let context = match launchdarkly_context(context) { + Ok(context) => context, + Err(_) => return flag.default(), + }; + + let detail = + self.client + .float_variation_detail(&context, flag.key(), flag.default() as f64); + + if detail.variation_index.is_none() { + return flag.default(); + } + + let Some(value) = detail.value else { + return flag.default(); + }; + + strict_f64_to_i64(value).unwrap_or_else(|| flag.default()) + } +} + +fn strict_f64_to_i64(value: f64) -> Option { + if !value.is_finite() || value.fract() != 0.0 { + return None; + } + + i64::try_from(value as i128).ok() +} + +fn validate_relay_proxy_endpoint(endpoint: &str) -> Result { + if endpoint.trim() != endpoint { + return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::MalformedUri, + )); + } + + if endpoint.contains('#') { + return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::MalformedUri, + )); + } + + let parsed = endpoint.parse::().map_err(|_| { + LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::MalformedUri, + ) + })?; + + if !matches!(parsed.scheme_str(), Some("http" | "https")) { + return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::InvalidScheme, + )); + } + + if parsed.query().is_some() { + return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::MalformedUri, + )); + } + + let Some(authority) = parsed.authority() else { + return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::MissingAuthority, + )); + }; + + if authority.host().is_empty() { + return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::MissingAuthority, + )); + } + + if authority_has_explicit_port(authority) && authority.port_u16().is_none() { + return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::MalformedUri, + )); + } + + Ok(endpoint.to_owned()) +} + +fn authority_has_explicit_port(authority: &Authority) -> bool { + let authority = authority + .as_str() + .rsplit_once('@') + .map_or_else(|| authority.as_str(), |(_, host_and_port)| host_and_port); + + if authority.starts_with('[') { + return authority.contains("]:"); + } + + authority + .rsplit_once(':') + .map(|(_, port)| !port.is_empty()) + .unwrap_or(false) +} + +/// Build the LaunchDarkly context used for evaluation. +/// +/// The adapter always includes a `community` kind context and optionally adds a +/// global `pubkey` kind context when an actor is present. +fn launchdarkly_context(context: &EvaluationContext) -> Result { + let mut community = ContextBuilder::new(context.community().to_string()); + community.kind(COMMUNITY_CONTEXT_KIND); + let community = community.build()?; + + let Some(actor_pubkey) = context.actor_pubkey() else { + return Ok(community); + }; + + let mut actor = ContextBuilder::new(actor_pubkey.to_hex()); + actor.kind(PUBKEY_CONTEXT_KIND); + let actor = actor.build()?; + + MultiContextBuilder::of(vec![community, actor]).build() +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::{CommunityId, PublicKey}; + use launchdarkly_server_sdk::{ + Config, Flag, FlagBuilder, FlagValue, Kind, NullEventProcessorBuilder, TestData, + }; + + fn community(id: &str) -> CommunityId { + CommunityId::from_uuid(id.parse().expect("valid community UUID")) + } + + fn actor() -> PublicKey { + PublicKey::from_hex("5581946f95a03e6afb43027ec89b21507f040d35fd6f8594f168f4298f96f9cb") + .expect("valid pubkey") + } + + fn kind(name: &'static str) -> Kind { + Kind::try_from(name).expect("valid LaunchDarkly context kind") + } + + fn weighted_rollout_flag(key: &str, context_kind: Option<&str>) -> Flag { + let context_kind_json = context_kind.map_or_else(String::new, |context_kind| { + format!(",\"contextKind\":\"{context_kind}\"") + }); + + serde_json::from_str(&format!( + r#"{{ + "key": "{key}", + "version": 1, + "on": true, + "targets": [], + "rules": [], + "prerequisites": [], + "fallthrough": {{ + "rollout": {{ + "variations": [ + {{ "variation": 0, "weight": 1 }}, + {{ "variation": 1, "weight": 99999 }} + ] + {context_kind_json} + }} + }}, + "offVariation": 0, + "variations": [false, true], + "clientSideAvailability": {{ + "usingMobileKey": false, + "usingEnvironmentId": false + }}, + "salt": "saltyA" + }}"# + )) + .expect("valid preconfigured weighted rollout flag") + } + + fn test_config(test_data: &TestData) -> Config { + ConfigBuilder::new("sdk-key") + .data_source(test_data) + .event_processor(&NullEventProcessorBuilder::new()) + .build() + .expect("launchdarkly test config") + } + + fn default_test_config(test_data: &TestData) -> Config { + ConfigBuilder::new("sdk-key") + .data_source(test_data) + .build() + .expect("launchdarkly default test config") + } + + async fn started_evaluator(test_data: &TestData) -> LaunchDarklyEvaluator { + let config = test_config(test_data); + let client = Client::build(config).expect("launchdarkly test client"); + let evaluator = LaunchDarklyEvaluator::new(client); + evaluator + .start_with_default_executor_and_wait(Duration::from_secs(1)) + .await + .expect("launchdarkly initialized"); + evaluator + } + + #[test] + fn launchdarkly_test_config_uses_null_event_processor() { + let test_data = TestData::new(); + let config = test_config(&test_data); + let default_config = default_test_config(&test_data); + let endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new() + .build() + .expect("default service endpoints"); + let event_processor = config + .event_processor_builder() + .build(&endpoints, config.sdk_key(), None) + .expect("event processor build"); + let default_event_processor = default_config + .event_processor_builder() + .build(&endpoints, default_config.sdk_key(), None) + .expect("default event processor build"); + + event_processor.close(); + default_event_processor.close(); + assert!( + event_processor.flush_blocking(Duration::from_millis(10)), + "null event processor should remain a no-op after close" + ); + assert!( + !default_event_processor.flush_blocking(Duration::from_millis(10)), + "default event processor should fail flush_blocking after close" + ); + } + + #[test] + fn runtime_config_debug_redacts_sdk_key() { + let config = LaunchDarklyRuntimeConfig::new("super-secret-sdk-key") + .with_relay_proxy_endpoint("https://relay.internal"); + let debug = format!("{config:?}"); + + assert!(!debug.contains("super-secret-sdk-key")); + assert!(debug.contains("[REDACTED]")); + assert!(debug.contains("https://relay.internal")); + } + + #[test] + fn malformed_relay_proxy_endpoint_returns_typed_error_without_panic() { + let result = std::panic::catch_unwind(|| { + LaunchDarklyEvaluator::from_runtime_config( + LaunchDarklyRuntimeConfig::new("sdk-key") + .with_relay_proxy_endpoint("not a valid endpoint"), + ) + }); + + assert!( + result.is_ok(), + "invalid endpoint should return Err, not panic" + ); + assert!(matches!( + result.expect("catch_unwind result"), + Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_)) + )); + } + + #[test] + fn relay_proxy_endpoint_rejects_non_http_scheme_and_missing_authority() { + let invalid_scheme = LaunchDarklyEvaluator::from_runtime_config( + LaunchDarklyRuntimeConfig::new("sdk-key") + .with_relay_proxy_endpoint("ftp://relay.internal:8030"), + ); + assert!(matches!( + invalid_scheme, + Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint( + InvalidRelayProxyEndpointReason::InvalidScheme + )) + )); + + for endpoint in [ + "https://", + "http:///missing-authority", + "https:///still-missing-authority", + "https://:8030/path-prefix", + ] { + let result = LaunchDarklyEvaluator::from_runtime_config( + LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint), + ); + assert!(matches!( + result, + Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_)) + )); + } + } + + #[test] + fn relay_proxy_endpoint_rejects_query_fragment_and_invalid_ports() { + let invalid_cases = [ + "https://relay.internal/path?env=staging", + "https://relay.internal/path#fragment", + "https://relay.internal:notaport/path-prefix", + "https://relay.internal:99999/path-prefix", + ]; + + for endpoint in invalid_cases { + let error = LaunchDarklyEvaluator::from_runtime_config( + LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint), + ) + .expect_err("endpoint should be rejected"); + + assert!(matches!( + error, + LaunchDarklyInitError::InvalidRelayProxyEndpoint(_) + )); + assert!(!format!("{error}").contains(endpoint)); + assert!(!format!("{error:?}").contains(endpoint)); + } + } + + #[test] + fn relay_proxy_endpoint_rejects_leading_or_trailing_whitespace() { + for endpoint in [ + " https://relay.internal:8030", + "https://relay.internal:8030 ", + "\thttps://relay.internal:8030", + "https://relay.internal:8030\n", + ] { + let result = LaunchDarklyEvaluator::from_runtime_config( + LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint), + ); + assert!(matches!( + result, + Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_)) + )); + } + } + + #[test] + fn relay_proxy_endpoint_accepts_absolute_http_and_https_uris() { + let valid_cases = [ + "http://relay.internal:8030", + "https://relay.internal", + "https://relay.internal:8443/path-prefix", + ]; + + for endpoint in valid_cases { + let result = LaunchDarklyEvaluator::from_runtime_config( + LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint), + ); + + assert!( + result.is_ok(), + "expected valid endpoint {endpoint} to be accepted" + ); + } + } + + #[test] + fn relay_proxy_endpoint_error_does_not_leak_raw_endpoint_value() { + let endpoint = "https://bad endpoint with spaces"; + let error = LaunchDarklyEvaluator::from_runtime_config( + LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint), + ) + .expect_err("invalid endpoint should fail"); + + assert!(!format!("{error}").contains(endpoint)); + assert!(!format!("{error:?}").contains(endpoint)); + } + + #[tokio::test] + async fn community_only_context_targets_named_community_kind() { + let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("community-only") + .fallthrough_variation(false) + .variation_for_key(kind("community"), community.to_string(), true), + ); + let evaluator = started_evaluator(&test_data).await; + + assert!(evaluator.evaluate_bool( + BooleanFlag::new("community-only", false), + &EvaluationContext::for_community(community), + )); + evaluator.close(); + } + + #[tokio::test] + async fn actor_context_targets_community_and_pubkey_kinds() { + let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let actor = actor(); + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("by-community") + .fallthrough_variation(false) + .variation_for_key(kind("community"), community.to_string(), true), + ); + test_data.update( + FlagBuilder::new("by-pubkey") + .fallthrough_variation(false) + .variation_for_key(kind("pubkey"), actor.to_hex(), true), + ); + let evaluator = started_evaluator(&test_data).await; + let context = EvaluationContext::for_actor(community, actor); + + assert!(evaluator.evaluate_bool(BooleanFlag::new("by-community", false), &context)); + assert!(evaluator.evaluate_bool(BooleanFlag::new("by-pubkey", false), &context)); + evaluator.close(); + } + + #[tokio::test] + async fn same_actor_can_receive_different_variations_by_community() { + let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + let actor = actor(); + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("community-rollout") + .fallthrough_variation(false) + .variation_for_key(kind("community"), community_a.to_string(), true), + ); + let evaluator = started_evaluator(&test_data).await; + let flag = BooleanFlag::new("community-rollout", false); + + assert!(evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_a, actor),)); + assert!(!evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_b, actor),)); + evaluator.close(); + } + + #[tokio::test] + async fn community_kind_weighted_rollout_uses_community_key_bucketing() { + let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let test_data = TestData::new(); + test_data.use_preconfigured_flag(weighted_rollout_flag( + "community-weighted-rollout", + Some("community"), + )); + test_data + .use_preconfigured_flag(weighted_rollout_flag("missing-context-kind-rollout", None)); + + let evaluator = started_evaluator(&test_data).await; + let context = EvaluationContext::for_community(community); + + assert!(evaluator.evaluate_bool( + BooleanFlag::new("community-weighted-rollout", false), + &context, + )); + assert!(!evaluator.evaluate_bool( + BooleanFlag::new("missing-context-kind-rollout", true), + &context, + )); + + evaluator.close(); + } + + #[tokio::test] + async fn missing_flag_falls_back_to_declared_default() { + let test_data = TestData::new(); + let evaluator = started_evaluator(&test_data).await; + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.missing", true), &context,)); + evaluator.close(); + } + + #[tokio::test] + async fn wrong_type_falls_back_to_declared_default() { + let test_data = TestData::new(); + test_data.update(FlagBuilder::new("relay.feature.bool").variation_for_all(true)); + test_data.update( + FlagBuilder::new("relay.feature.string") + .value_for_all(FlagValue::Str("red".to_owned())), + ); + let evaluator = started_evaluator(&test_data).await; + let context = EvaluationContext::for_actor( + community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + actor(), + ); + + assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.bool", false), &context,)); + assert!( + !evaluator.evaluate_bool(BooleanFlag::new("relay.feature.string", false), &context,) + ); + evaluator.close(); + } + + #[tokio::test] + async fn community_only_context_targets_integer_variation() { + let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("community-int") + .variations([FlagValue::from(0_i64), FlagValue::from(13_i64)]) + .fallthrough_variation_index(0) + .variation_index_for_key(kind("community"), community.to_string(), 1), + ); + let evaluator = started_evaluator(&test_data).await; + + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("community-int", -1), + &EvaluationContext::for_community(community), + ), + 13 + ); + evaluator.close(); + } + + #[tokio::test] + async fn actor_context_targets_integer_variation_by_community_and_pubkey() { + let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let actor = actor(); + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("by-community-int") + .variations([FlagValue::from(0_i64), FlagValue::from(8_i64)]) + .fallthrough_variation_index(0) + .variation_index_for_key(kind("community"), community.to_string(), 1), + ); + test_data.update( + FlagBuilder::new("by-pubkey-int") + .variations([FlagValue::from(0_i64), FlagValue::from(21_i64)]) + .fallthrough_variation_index(0) + .variation_index_for_key(kind("pubkey"), actor.to_hex(), 1), + ); + let evaluator = started_evaluator(&test_data).await; + let context = EvaluationContext::for_actor(community, actor); + + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("by-community-int", -1), &context), + 8 + ); + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("by-pubkey-int", -1), &context), + 21 + ); + evaluator.close(); + } + + #[tokio::test] + async fn same_actor_can_receive_different_integer_variations_by_community() { + let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + let actor = actor(); + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("community-int-rollout") + .variations([FlagValue::from(-11_i64), FlagValue::from(99_i64)]) + .fallthrough_variation_index(0) + .variation_index_for_key(kind("community"), community_a.to_string(), 1), + ); + let evaluator = started_evaluator(&test_data).await; + + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("community-int-rollout", -1), + &EvaluationContext::for_actor(community_a, actor), + ), + 99 + ); + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("community-int-rollout", -1), + &EvaluationContext::for_actor(community_b, actor), + ), + -11 + ); + evaluator.close(); + } + + #[tokio::test] + async fn missing_integer_flag_falls_back_to_declared_default() { + let test_data = TestData::new(); + let evaluator = started_evaluator(&test_data).await; + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("relay.feature.missing-int", -7), &context), + -7 + ); + evaluator.close(); + } + + #[tokio::test] + async fn missing_integer_flag_preserves_exact_declared_default_bytes() { + let test_data = TestData::new(); + let evaluator = started_evaluator(&test_data).await; + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] { + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.missing-int-precise-default", default), + &context, + ), + default, + "missing-flag fallback must preserve exact declared default" + ); + } + + evaluator.close(); + } + + #[tokio::test] + async fn wrong_type_integer_falls_back_to_declared_default() { + let test_data = TestData::new(); + test_data + .update(FlagBuilder::new("relay.feature.int").value_for_all(FlagValue::from(17_i64))); + test_data.update( + FlagBuilder::new("relay.feature.int-bool").value_for_all(FlagValue::from(true)), + ); + let evaluator = started_evaluator(&test_data).await; + let context = EvaluationContext::for_actor( + community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + actor(), + ); + + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("relay.feature.int", 0), &context), + 17 + ); + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("relay.feature.int-bool", -5), &context), + -5 + ); + evaluator.close(); + } + + #[tokio::test] + async fn wrong_type_integer_flag_preserves_exact_declared_default_bytes() { + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("relay.feature.int-wrong-type-for-precise-default") + .value_for_all(FlagValue::from(true)), + ); + let evaluator = started_evaluator(&test_data).await; + let context = EvaluationContext::for_actor( + community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + actor(), + ); + + for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] { + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.int-wrong-type-for-precise-default", default), + &context, + ), + default, + "wrong-type fallback must preserve exact declared default" + ); + } + + evaluator.close(); + } + + #[tokio::test] + async fn provider_not_ready_preserves_exact_declared_default_bytes() { + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("relay.feature.int-provider-not-ready") + .value_for_all(FlagValue::from(123_i64)), + ); + + let config = test_config(&test_data); + let client = Client::build(config).expect("launchdarkly test client"); + let evaluator = LaunchDarklyEvaluator::new(client); + let context = EvaluationContext::for_actor( + community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + actor(), + ); + + for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] { + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.int-provider-not-ready", default), + &context, + ), + default, + "provider-not-ready fallback must preserve exact declared default" + ); + } + + evaluator.close(); + } + + #[tokio::test] + async fn fractional_integer_variation_falls_back_to_declared_default() { + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("relay.feature.int-fractional-positive") + .value_for_all(FlagValue::from(17.75_f64)), + ); + test_data.update( + FlagBuilder::new("relay.feature.int-fractional-negative") + .value_for_all(FlagValue::from(-17.75_f64)), + ); + let evaluator = started_evaluator(&test_data).await; + let context = EvaluationContext::for_actor( + community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + actor(), + ); + + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.int-fractional-positive", 123), + &context, + ), + 123 + ); + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.int-fractional-negative", -456), + &context, + ), + -456 + ); + evaluator.close(); + } + + #[tokio::test] + async fn integer_variation_accepts_exact_large_f64_backed_i64_values() { + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("relay.feature.int-large-positive") + .value_for_all(FlagValue::from(1_152_921_504_606_846_976_i64)), + ); + test_data.update( + FlagBuilder::new("relay.feature.int-large-negative") + .value_for_all(FlagValue::from(-1_152_921_504_606_846_976_i64)), + ); + test_data.update( + FlagBuilder::new("relay.feature.int-min-boundary") + .value_for_all(FlagValue::from(i64::MIN)), + ); + test_data.update( + FlagBuilder::new("relay.feature.int-max-rounds-out-of-range") + .value_for_all(FlagValue::from(i64::MAX)), + ); + let evaluator = started_evaluator(&test_data).await; + let context = EvaluationContext::for_actor( + community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + actor(), + ); + + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.int-large-positive", 0), + &context, + ), + 1_152_921_504_606_846_976_i64 + ); + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.int-large-negative", 0), + &context, + ), + -1_152_921_504_606_846_976_i64 + ); + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.int-min-boundary", 11), + &context, + ), + i64::MIN + ); + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("relay.feature.int-max-rounds-out-of-range", -12), + &context, + ), + -12 + ); + evaluator.close(); + } + + #[tokio::test] + async fn negative_integer_variation_is_supported() { + let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let test_data = TestData::new(); + test_data.update(FlagBuilder::new("negative-int").value_for_all(FlagValue::from(-42_i64))); + let evaluator = started_evaluator(&test_data).await; + + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("negative-int", 3), + &EvaluationContext::for_community(community), + ), + -42 + ); + evaluator.close(); + } +} diff --git a/crates/buzz-feature-flags/src/lib.rs b/crates/buzz-feature-flags/src/lib.rs new file mode 100644 index 0000000000..c9169b176b --- /dev/null +++ b/crates/buzz-feature-flags/src/lib.rs @@ -0,0 +1,217 @@ +#![deny(unsafe_code)] +#![warn(missing_docs)] +//! Typed boolean and integer feature flags for Buzz server components. + +use buzz_core::{CommunityId, PublicKey}; + +pub mod environment; +pub use environment::{EnvironmentDiagnostic, EnvironmentEvaluator}; + +pub mod flags; +pub use flags::{BooleanFlag, IntegerFlag}; + +#[cfg(feature = "launchdarkly")] +/// LaunchDarkly-backed evaluator adapter. +pub mod launchdarkly; + +/// Stable targeting context for one Buzz community and an optional actor. +/// +/// Community is always required because the same Nostr identity can participate +/// in multiple isolated Buzz communities. Community-only contexts support +/// background and system evaluations that have no actor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EvaluationContext { + community: CommunityId, + actor_pubkey: Option, +} + +impl EvaluationContext { + /// Construct a community-only evaluation context. + pub const fn for_community(community: CommunityId) -> Self { + Self { + community, + actor_pubkey: None, + } + } + + /// Construct an evaluation context for an actor within one community. + pub const fn for_actor(community: CommunityId, actor_pubkey: PublicKey) -> Self { + Self { + community, + actor_pubkey: Some(actor_pubkey), + } + } + + /// Community that owns this evaluation. + pub const fn community(&self) -> CommunityId { + self.community + } + + /// Optional stable actor key for actor-targeted evaluation. + pub const fn actor_pubkey(&self) -> Option<&PublicKey> { + self.actor_pubkey.as_ref() + } +} + +/// Evaluates typed feature flags. +pub trait FlagEvaluator: Send + Sync { + /// Resolve a boolean flag for one Buzz evaluation context. + /// + /// Implementations must return [`BooleanFlag::default`] whenever no valid + /// boolean value is available, including provider absence or failure. + fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool; + + /// Resolve an integer flag for one Buzz evaluation context. + /// + /// Implementations must return [`IntegerFlag::default`] whenever no valid + /// integer value is available, including provider absence or failure. + fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64; +} + +/// Static evaluator that should return each flag's declared default. +#[derive(Debug, Default, Clone, Copy)] +pub struct StaticEvaluator; + +impl FlagEvaluator for StaticEvaluator { + fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool { + flag.default() + } + + fn evaluate_int(&self, flag: IntegerFlag, _context: &EvaluationContext) -> i64 { + flag.default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::CommunityId; + + fn community(id: &str) -> CommunityId { + CommunityId::from_uuid(id.parse().expect("valid community UUID")) + } + + fn actor() -> PublicKey { + PublicKey::from_hex("c4f0623bdc8c4f7ecab9f7457f501f3e8f4efcf8f8f6ef6f4d76f42f5bb6f2cb") + .expect("valid pubkey") + } + + #[test] + fn static_evaluator_returns_declared_defaults_for_community() { + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(StaticEvaluator.evaluate_bool(BooleanFlag::new("enabled", true), &context)); + assert!(!StaticEvaluator.evaluate_bool(BooleanFlag::new("disabled", false), &context)); + } + + #[test] + fn static_evaluator_returns_declared_integer_defaults_for_community() { + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert_eq!( + StaticEvaluator.evaluate_int(IntegerFlag::new("positive", 7), &context), + 7 + ); + assert_eq!( + StaticEvaluator.evaluate_int(IntegerFlag::new("negative", -9), &context), + -9 + ); + } + + #[test] + fn non_launchdarkly_evaluator_can_target_community_through_public_trait() { + struct CommunityEvaluator { + enabled_community: CommunityId, + } + + impl FlagEvaluator for CommunityEvaluator { + fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool { + if context.community() == self.enabled_community { + true + } else { + flag.default() + } + } + + fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { + if context.community() == self.enabled_community { + 41 + } else { + flag.default() + } + } + } + + let enabled_community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let other_community = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + let evaluator: &dyn FlagEvaluator = &CommunityEvaluator { enabled_community }; + + assert!(evaluator.evaluate_bool( + BooleanFlag::new("community-targeted-bool", false), + &EvaluationContext::for_community(enabled_community), + )); + assert!(!evaluator.evaluate_bool( + BooleanFlag::new("community-targeted-bool", false), + &EvaluationContext::for_community(other_community), + )); + + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("community-targeted-int", -1), + &EvaluationContext::for_community(enabled_community), + ), + 41 + ); + assert_eq!( + evaluator.evaluate_int( + IntegerFlag::new("community-targeted-int", -1), + &EvaluationContext::for_community(other_community), + ), + -1 + ); + } + + #[test] + fn same_actor_in_two_communities_is_distinguishable_for_integer_evaluation() { + struct CommunityRolloutEvaluator { + enabled_community: CommunityId, + expected_actor: PublicKey, + } + + impl FlagEvaluator for CommunityRolloutEvaluator { + fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool { + flag.default() + } + + fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { + if context.community() == self.enabled_community + && context.actor_pubkey() == Some(&self.expected_actor) + { + 99 + } else { + flag.default() + } + } + } + + let actor = actor(); + let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + let evaluator: &dyn FlagEvaluator = &CommunityRolloutEvaluator { + enabled_community: community_a, + expected_actor: actor, + }; + let flag = IntegerFlag::new("community-rollout", -11); + + assert_eq!( + evaluator.evaluate_int(flag, &EvaluationContext::for_actor(community_a, actor)), + 99 + ); + assert_eq!( + evaluator.evaluate_int(flag, &EvaluationContext::for_actor(community_b, actor)), + -11 + ); + } +} diff --git a/crates/buzz-feature-flags/tests/environment_evaluator.rs b/crates/buzz-feature-flags/tests/environment_evaluator.rs new file mode 100644 index 0000000000..501a8dd934 --- /dev/null +++ b/crates/buzz-feature-flags/tests/environment_evaluator.rs @@ -0,0 +1,336 @@ +use std::sync::Arc; + +use buzz_core::{CommunityId, PublicKey}; +use buzz_feature_flags::{ + BooleanFlag, EnvironmentDiagnostic, EnvironmentEvaluator, EvaluationContext, FlagEvaluator, + IntegerFlag, +}; + +const ENV_NAMESPACE: &str = "BUZZ_FEATURE_FLAG_"; + +fn community(id: &str) -> CommunityId { + CommunityId::from_uuid(id.parse().expect("valid community UUID")) +} + +fn actor(hex: &str) -> PublicKey { + PublicKey::from_hex(hex).expect("valid pubkey") +} + +#[test] +fn parses_booleans_case_insensitive_with_trim() { + let evaluator = EnvironmentEvaluator::from_pairs([ + ( + format!("{ENV_NAMESPACE}RELAY_BOOL_TRUE"), + " TrUe \t".to_owned(), + ), + ( + format!("{ENV_NAMESPACE}RELAY_BOOL_FALSE"), + "\n false ".to_owned(), + ), + ]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.true", false), &context)); + assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.bool.false", true), &context)); +} + +#[test] +fn parses_signed_negative_integers_with_trim() { + let evaluator = EnvironmentEvaluator::from_pairs([( + format!("{ENV_NAMESPACE}DB_EVENTS_QUERY_VERSION"), + " -17 ".to_owned(), + )]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("db.events.query-version", 1), &context), + -17 + ); +} + +#[test] +fn missing_flag_values_fall_back_to_declared_defaults() { + let evaluator = EnvironmentEvaluator::from_pairs([] as [(String, String); 0]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.missing", true), &context)); + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("relay.int.missing", -9), &context), + -9 + ); +} + +#[test] +fn invalid_and_empty_values_fall_back_to_declared_defaults() { + let evaluator = EnvironmentEvaluator::from_pairs([ + ( + format!("{ENV_NAMESPACE}RELAY_BOOL_INVALID"), + "maybe".to_owned(), + ), + (format!("{ENV_NAMESPACE}RELAY_BOOL_EMPTY"), " ".to_owned()), + ( + format!("{ENV_NAMESPACE}RELAY_INT_INVALID"), + "12.5".to_owned(), + ), + ]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.bool.invalid", false), &context)); + assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.empty", true), &context)); + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("relay.int.invalid", 88), &context), + 88 + ); + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("!!!", -4), &context), + -4, + ); +} + +#[test] +fn evaluation_context_is_ignored_for_process_wide_values() { + let evaluator = EnvironmentEvaluator::from_pairs([( + format!("{ENV_NAMESPACE}RELAY_PROCESS_WIDE"), + "true".to_owned(), + )]); + let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + let actor_a = actor("c4f0623bdc8c4f7ecab9f7457f501f3e8f4efcf8f8f6ef6f4d76f42f5bb6f2cb"); + let actor_b = actor("f1e2d3c4b5a697887766554433221100ffeeddccbbaa99887766554433221100"); + + assert!(evaluator.evaluate_bool( + BooleanFlag::new("relay.process-wide", false), + &EvaluationContext::for_actor(community_a, actor_a), + )); + assert!(evaluator.evaluate_bool( + BooleanFlag::new("relay.process-wide", false), + &EvaluationContext::for_actor(community_b, actor_b), + )); + assert!(evaluator.evaluate_bool( + BooleanFlag::new("relay.process-wide", false), + &EvaluationContext::for_community(community_a), + )); +} + +#[test] +fn normalization_replaces_non_alnum_runs_and_trims_separators() { + let evaluator = EnvironmentEvaluator::from_pairs([( + format!("{ENV_NAMESPACE}RELAY_FOO_BAR_BAZ_9"), + "true".to_owned(), + )]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(evaluator.evaluate_bool( + BooleanFlag::new("___relay..foo---bar***baz??9___", false), + &context, + )); +} + +#[test] +fn colliding_flag_keys_share_normalized_environment_name() { + let evaluator = + EnvironmentEvaluator::from_pairs([(format!("{ENV_NAMESPACE}A_B"), "true".to_owned())]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(evaluator.evaluate_bool(BooleanFlag::new("a-b", false), &context)); + assert!(evaluator.evaluate_bool(BooleanFlag::new("a_b", false), &context)); +} + +#[test] +fn snapshot_is_immutable_after_construction() { + let mut source = vec![(format!("{ENV_NAMESPACE}SNAPSHOT_BOOL"), "true".to_owned())]; + + let evaluator = EnvironmentEvaluator::from_pairs( + source + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())), + ); + + source[0].1 = "false".to_owned(); + source.push((format!("{ENV_NAMESPACE}SNAPSHOT_INT"), "99".to_owned())); + + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + assert!(evaluator.evaluate_bool(BooleanFlag::new("snapshot.bool", false), &context)); + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("snapshot.int", -1), &context), + -1 + ); +} + +#[test] +fn debug_does_not_expose_environment_contents() { + let evaluator = EnvironmentEvaluator::from_pairs([( + format!("{ENV_NAMESPACE}SECRET_FLAG"), + "super-secret-value".to_owned(), + )]); + + let debug = format!("{evaluator:?}"); + assert!(!debug.contains("super-secret-value")); + assert!(!debug.contains("SECRET_FLAG")); +} + +#[test] +fn malformed_present_values_default_and_diagnose_once_per_key_and_type() { + let evaluator = EnvironmentEvaluator::from_pairs([ + ( + format!("{ENV_NAMESPACE}RELAY_INVALID_BOOL"), + "raw-secret-bool".to_owned(), + ), + ( + format!("{ENV_NAMESPACE}RELAY_INVALID_INT"), + "raw-secret-int".to_owned(), + ), + ("DATABASE_URL".to_owned(), "unrelated-secret".to_owned()), + ]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + let bool_flag = BooleanFlag::new("relay.invalid-bool", true); + let int_flag = IntegerFlag::new("relay.invalid-int", -12); + + assert!(evaluator.evaluate_bool(bool_flag, &context)); + assert!(evaluator.evaluate_bool(bool_flag, &context)); + assert_eq!(evaluator.evaluate_int(int_flag, &context), -12); + assert_eq!(evaluator.evaluate_int(int_flag, &context), -12); + + let diagnostics = evaluator.take_diagnostics(); + assert_eq!( + diagnostics, + vec![ + EnvironmentDiagnostic::InvalidBooleanValue { + flag_key: "relay.invalid-bool", + }, + EnvironmentDiagnostic::InvalidIntegerValue { + flag_key: "relay.invalid-int", + }, + ] + ); + + let rendered = format!("{diagnostics:?}"); + assert!(!rendered.contains("raw-secret-bool")); + assert!(!rendered.contains("raw-secret-int")); + assert!(!rendered.contains("unrelated-secret")); + assert!(!rendered.contains("DATABASE_URL")); + + assert!(evaluator.evaluate_bool(bool_flag, &context)); + assert_eq!(evaluator.evaluate_int(int_flag, &context), -12); + assert!(evaluator.take_diagnostics().is_empty()); +} + +#[test] +fn trait_object_evaluation_reports_through_retained_concrete_owner() { + let owner = Arc::new(EnvironmentEvaluator::from_pairs([( + format!("{ENV_NAMESPACE}RELAY_INVALID"), + "not-a-boolean".to_owned(), + )])); + let evaluator: Arc = owner.clone(); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.invalid", true), &context)); + assert_eq!( + owner.take_diagnostics(), + vec![EnvironmentDiagnostic::InvalidBooleanValue { + flag_key: "relay.invalid", + }] + ); +} + +#[test] +fn same_flag_key_reports_distinct_boolean_and_integer_diagnostics() { + let evaluator = EnvironmentEvaluator::from_pairs([( + format!("{ENV_NAMESPACE}RELAY_SHARED_KEY"), + "invalid-for-both-types".to_owned(), + )]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.shared-key", false), &context)); + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("relay.shared-key", -4), &context), + -4 + ); + assert_eq!( + evaluator.take_diagnostics(), + vec![ + EnvironmentDiagnostic::InvalidBooleanValue { + flag_key: "relay.shared-key", + }, + EnvironmentDiagnostic::InvalidIntegerValue { + flag_key: "relay.shared-key", + }, + ] + ); +} + +#[test] +fn cloned_evaluators_share_dedup_before_and_after_drain() { + let evaluator = EnvironmentEvaluator::from_pairs([( + format!("{ENV_NAMESPACE}RELAY_CLONED"), + "not-a-boolean".to_owned(), + )]); + let clone = evaluator.clone(); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + let flag = BooleanFlag::new("relay.cloned", false); + + assert!(!evaluator.evaluate_bool(flag, &context)); + assert!(!clone.evaluate_bool(flag, &context)); + assert_eq!( + clone.take_diagnostics(), + vec![EnvironmentDiagnostic::InvalidBooleanValue { + flag_key: "relay.cloned", + }] + ); + assert!(evaluator.take_diagnostics().is_empty()); + + assert!(!evaluator.evaluate_bool(flag, &context)); + assert!(!clone.evaluate_bool(flag, &context)); + assert!(evaluator.take_diagnostics().is_empty()); + assert!(clone.take_diagnostics().is_empty()); +} + +#[test] +fn missing_values_default_without_diagnostics() { + let evaluator = EnvironmentEvaluator::from_pairs([] as [(String, String); 0]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + + assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.missing-bool", true), &context)); + assert_eq!( + evaluator.evaluate_int(IntegerFlag::new("relay.missing-int", -4), &context), + -4 + ); + assert!(evaluator.take_diagnostics().is_empty()); +} + +#[cfg(unix)] +#[test] +fn namespaced_non_unicode_value_defaults_and_diagnoses_once() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let evaluator = EnvironmentEvaluator::from_pairs([( + OsString::from(format!("{ENV_NAMESPACE}RELAY_NON_UNICODE")), + OsString::from_vec(vec![0x80]), + )]); + let context = + EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + let flag = BooleanFlag::new("relay.non-unicode", false); + + assert!(!evaluator.evaluate_bool(flag, &context)); + assert!(!evaluator.evaluate_bool(flag, &context)); + assert_eq!( + evaluator.take_diagnostics(), + vec![EnvironmentDiagnostic::InvalidBooleanValue { + flag_key: "relay.non-unicode", + }] + ); + assert!(!evaluator.evaluate_bool(flag, &context)); + assert!(evaluator.take_diagnostics().is_empty()); +} diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock new file mode 100644 index 0000000000..6f0ec9a2bb --- /dev/null +++ b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock @@ -0,0 +1,2797 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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 = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[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 = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[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 = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.3", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "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 = "buzz-core" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "hmac 0.13.0", + "nostr", + "percent-encoding", + "rand 0.10.3", + "serde", + "serde_json", + "sha2 0.11.0", + "subtle", + "thiserror", + "url", + "uuid", + "zeroize", +] + +[[package]] +name = "buzz-feature-flags" +version = "0.1.0" +dependencies = [ + "buzz-core", + "http", + "launchdarkly-server-sdk", + "thiserror", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "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", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cidr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[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", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[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.6", +] + +[[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 = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "eventsource-client" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96df8cfa11d3c8e4e1a48b81c9c600ecbe8c11d1326f41e1524cc4d42f7bf6d9" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "http", + "launchdarkly-sdk-transport", + "log", + "pin-project", + "rand 0.10.3", + "tokio", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +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 = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[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-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "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", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-http-proxy" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "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", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +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 = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "launchdarkly-sdk-transport" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032d66802c461d7254ed1de887be6ca98ebbe126867d9a60c754baae81c4d10d" +dependencies = [ + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "log", + "no-proxy", + "tower", +] + +[[package]] +name = "launchdarkly-server-sdk" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22259877ec581fa56a9f0b982e583e16fa01b71ba6d3f786ef5ccfc6ca6f92ff" +dependencies = [ + "aws-lc-rs", + "bitflags 2.13.2", + "bytes", + "chrono", + "crossbeam-channel", + "data-encoding", + "eventsource-client", + "flate2", + "futures", + "http", + "launchdarkly-sdk-transport", + "launchdarkly-server-sdk-evaluation", + "log", + "lru", + "moka", + "parking_lot", + "rand 0.9.5", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "launchdarkly-server-sdk-evaluation" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76560a130b2a7e7fdb9c56043168844cde483e08e7f0e4a5e178e3888edc4336" +dependencies = [ + "base16ct", + "chrono", + "itertools", + "lazy_static", + "log", + "maplit", + "regex", + "semver", + "serde", + "serde_json", + "serde_with", + "sha1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "no-proxy" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd3f71e44f009513a82ac2612c0c2da7e2924fad776931164317baa9695e150" +dependencies = [ + "cidr", +] + +[[package]] +name = "nostr" +version = "0.44.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" +dependencies = [ + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[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 = "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +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 = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "relay-feature-selection-fixture" +version = "0.0.0" +dependencies = [ + "buzz-feature-flags", +] + +[[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 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +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 = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[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.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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.8", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[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.6", +] + +[[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 = "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.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[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 = "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml new file mode 100644 index 0000000000..2f6cb93f20 --- /dev/null +++ b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "relay-feature-selection-fixture" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[features] +static-feature-flags = [] +environment-feature-flags = [] +launchdarkly-feature-flags = ["buzz-feature-flags/launchdarkly"] + +[dependencies] +buzz-feature-flags = { path = "../../.." } diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs new file mode 100644 index 0000000000..b979395fee --- /dev/null +++ b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs @@ -0,0 +1,103 @@ +#![allow(dead_code)] + +use std::sync::Arc; + +use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator}; +#[cfg(feature = "static-feature-flags")] +use buzz_feature_flags::StaticEvaluator; +#[cfg(feature = "launchdarkly-feature-flags")] +use buzz_feature_flags::launchdarkly::{ + LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig, +}; + +#[cfg(not(any( + feature = "static-feature-flags", + feature = "environment-feature-flags", + feature = "launchdarkly-feature-flags", +)))] +compile_error!( + "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" +); + +#[cfg(any( + all(feature = "static-feature-flags", feature = "environment-feature-flags"), + all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"), + all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"), +))] +compile_error!( + "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" +); + +#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))] +#[derive(Clone, Debug, Default)] +pub struct FeatureFlagRuntimeConfig; + +#[cfg(feature = "launchdarkly-feature-flags")] +#[derive(Clone, Debug)] +pub struct FeatureFlagRuntimeConfig { + pub sdk_key: String, + pub relay_proxy_endpoint: Option, +} + +pub struct FeatureFlagCompositionRoot { + pub feature_flags: Arc, + pub environment_diagnostics: Option>, + #[cfg(feature = "launchdarkly-feature-flags")] + pub launchdarkly_lifecycle: Option>, +} + +#[cfg(feature = "static-feature-flags")] +pub fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> FeatureFlagCompositionRoot { + FeatureFlagCompositionRoot { + feature_flags: Arc::new(StaticEvaluator), + environment_diagnostics: None, + } +} + +#[cfg(feature = "environment-feature-flags")] +pub fn build_feature_flag_evaluator( + _config: FeatureFlagRuntimeConfig, +) -> FeatureFlagCompositionRoot { + let owner = Arc::new(EnvironmentEvaluator::from_process_environment()); + let feature_flags: Arc = owner.clone(); + FeatureFlagCompositionRoot { + feature_flags, + environment_diagnostics: Some(owner), + } +} + +#[cfg(feature = "launchdarkly-feature-flags")] +pub fn build_feature_flag_evaluator( + config: FeatureFlagRuntimeConfig, +) -> Result { + let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key); + if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint { + runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint); + } + + let owner = Arc::new(LaunchDarklyEvaluator::from_runtime_config(runtime_config)?); + let feature_flags: Arc = owner.clone(); + Ok(FeatureFlagCompositionRoot { + feature_flags, + environment_diagnostics: None, + launchdarkly_lifecycle: Some(owner), + }) +} + +#[cfg(feature = "static-feature-flags")] +pub fn compile_smoke() { + let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig); +} + +#[cfg(feature = "environment-feature-flags")] +pub fn compile_smoke() { + let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig); +} + +#[cfg(feature = "launchdarkly-feature-flags")] +pub fn compile_smoke() { + let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig { + sdk_key: "fixture-sdk-key".to_owned(), + relay_proxy_endpoint: None, + }); +} diff --git a/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs b/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs new file mode 100644 index 0000000000..120ad58787 --- /dev/null +++ b/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs @@ -0,0 +1,229 @@ +use std::{ + env, + ffi::OsString, + path::{Path, PathBuf}, + process::Command, +}; +use tempfile::TempDir; + +const FIXTURE_LOCK_REGEN_RECIPE: &str = + "cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml"; + +fn fixture_manifest() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.toml") +} + +fn fixture_lockfile() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.lock") +} + +fn crate_readme() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md") +} + +fn cargo_bin() -> OsString { + env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")) +} + +fn cargo_check(target_dir: &Path, case_name: &str, features: &[&str]) -> std::process::Output { + let mut command = Command::new(cargo_bin()); + command + .current_dir(env!("CARGO_MANIFEST_DIR")) + .arg("check") + .arg("--quiet") + .arg("--locked") + .arg("--manifest-path") + .arg(fixture_manifest()) + .arg("--no-default-features") + .env("CARGO_TARGET_DIR", target_dir); + + if !features.is_empty() { + command.arg("--features").arg(features.join(",")); + } + + command + .output() + .unwrap_or_else(|error| { + panic!( + "failed to run cargo check for {case_name}: {error}\nfixture lock regeneration recipe: `{FIXTURE_LOCK_REGEN_RECIPE}`" + ) + }) +} + +fn format_valid_case_failed_status(case_name: &str, stdout: &str, stderr: &str) -> String { + format!( + "expected {case_name} to compile successfully\nstdout:\n{stdout}\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`" + ) +} + +fn format_invalid_case_missing_exact_one(case_name: &str, stderr: &str) -> String { + format!( + "expected {case_name} failure to explain the exact-one contract\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`" + ) +} + +fn assert_valid_case_success(case_name: &str, output: &std::process::Output) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "{}", + format_valid_case_failed_status(case_name, &stdout, &stderr) + ); +} + +fn assert_invalid_case_has_exact_one_diagnostic(case_name: &str, output: &std::process::Output) { + assert!( + !output.status.success(), + "expected {case_name} to fail compilation" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("select exactly one relay feature flag provider feature"), + "{}", + format_invalid_case_missing_exact_one(case_name, &stderr) + ); +} + +#[test] +fn relay_fixture_lock_regeneration_recipe_is_documented() { + let readme = std::fs::read_to_string(crate_readme()).expect("read crate README"); + assert!( + readme.contains(FIXTURE_LOCK_REGEN_RECIPE), + "README.md must document fixture lock regeneration with `{FIXTURE_LOCK_REGEN_RECIPE}`" + ); +} + +#[test] +fn relay_readme_documents_fail_closed_launchdarkly_startup_contract() { + let readme = std::fs::read_to_string(crate_readme()).expect("read crate README"); + + assert!( + readme.contains("start_with_default_executor_and_wait"), + "README.md must show LaunchDarkly startup via start_with_default_executor_and_wait" + ); + assert!( + readme.contains("async fn build_feature_flag_evaluator"), + "README.md must declare LaunchDarkly build_feature_flag_evaluator as async" + ); + assert!( + readme.contains( + "start_with_default_executor_and_wait(Duration::from_secs(5))\n .await" + ), + "README.md must await start_with_default_executor_and_wait" + ); + assert!( + readme.contains("fail-closed"), + "README.md must state fail-closed startup policy" + ); + assert!( + readme.contains("exact-one compile_error guards"), + "README.md must require exact-one compile_error guards in the consumer" + ); + assert!( + readme.contains("future flag registry"), + "README.md must defer environment-name uniqueness enforcement to a future registry" + ); + assert!( + readme.contains("kind `community` plus") + && readme.contains("optional global kind `pubkey`"), + "README.md must document LaunchDarkly adapter context kinds" + ); + assert!( + readme.contains("rollouts must set `contextKind` to a kind present") + && readme.contains("in evaluation"), + "README.md must document rollout contextKind requirements" + ); + assert!( + readme.contains("omits `contextKind` defaults") && readme.contains("to `user`;"), + "README.md must document LaunchDarkly default rollout context kind" + ); + assert!( + readme.contains("bucket as") + && readme.contains("zero and pick the first positive-weight variation"), + "README.md must document missing-kind rollout bucketing behavior" + ); + assert!( + readme.contains("close()` blocks the calling thread") + && readme.contains("while it flushes analytics"), + "README.md must document blocking LaunchDarkly close semantics" + ); + assert!( + readme.contains("offload it to a blocking shutdown path"), + "README.md must document async shutdown offloading guidance for close()" + ); +} + +#[test] +fn valid_case_failed_status_formatter_includes_fixture_lock_regeneration_recipe() { + let message = format_valid_case_failed_status( + "static", + "", + "error: lock file needs update but --locked was passed", + ); + assert!( + message.contains(FIXTURE_LOCK_REGEN_RECIPE), + "valid-case failure formatter must include fixture lock recipe\nmessage:\n{message}" + ); +} + +#[test] +fn invalid_case_missing_exact_one_formatter_includes_fixture_lock_regeneration_recipe() { + let message = + format_invalid_case_missing_exact_one("none", "error: unrelated nested cargo failure"); + assert!( + message.contains(FIXTURE_LOCK_REGEN_RECIPE), + "invalid-case failure formatter must include fixture lock recipe\nmessage:\n{message}" + ); +} + +#[test] +fn relay_artifact_provider_features_require_exactly_one_selection() { + assert!( + fixture_lockfile().is_file(), + "relay feature selection fixture must check in Cargo.lock for locked nested cargo runs\nregenerate it with `{FIXTURE_LOCK_REGEN_RECIPE}`" + ); + + let target_dir = TempDir::new().expect("temp target dir"); + + let valid_cases = [ + ("static", &["static-feature-flags"][..]), + ("environment", &["environment-feature-flags"][..]), + ("launchdarkly", &["launchdarkly-feature-flags"][..]), + ]; + + for (case_name, features) in valid_cases { + let output = cargo_check(target_dir.path(), case_name, features); + assert_valid_case_success(case_name, &output); + } + + let invalid_cases = [ + ("none", &[][..]), + ( + "static-and-environment", + &["static-feature-flags", "environment-feature-flags"][..], + ), + ( + "static-and-launchdarkly", + &["static-feature-flags", "launchdarkly-feature-flags"][..], + ), + ( + "environment-and-launchdarkly", + &["environment-feature-flags", "launchdarkly-feature-flags"][..], + ), + ( + "all-three", + &[ + "static-feature-flags", + "environment-feature-flags", + "launchdarkly-feature-flags", + ][..], + ), + ]; + + for (case_name, features) in invalid_cases { + let output = cargo_check(target_dir.path(), case_name, features); + assert_invalid_case_has_exact_one_diagnostic(case_name, &output); + } +} diff --git a/crates/buzz-mesh-smoke/Cargo.toml b/crates/buzz-mesh-smoke/Cargo.toml new file mode 100644 index 0000000000..5490bf81d3 --- /dev/null +++ b/crates/buzz-mesh-smoke/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "buzz-mesh-smoke" +description = "Hardware/CI mesh-llm smoke harnesses (examples only; no library or binary ships)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +# Examples-only crate. These harnesses link the mesh-llm SDK and native +# runtime, which is by far the most expensive subtree in the repository. +# They live here rather than in `crates/buzz-relay/examples/` because a +# dev-dependency feeds every target of its own package: with these harnesses +# under buzz-relay, `cargo nextest archive -p buzz-relay` had to build the +# whole mesh-llm + skippy + rmcp tree before it could compile a single relay +# unit test. That is what pushed the Desktop E2E Relay job past its 30 minute +# budget. None of the harnesses reference `buzz_relay::` — they drive the +# relay as an external process — so nothing is lost by isolating them. +[lib] +path = "src/lib.rs" + +[dependencies] + +[dev-dependencies] +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.2", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.2", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +# Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): +# the relay client for discovery notes and the exact ed25519 the mesh owner +# keys use for binding verification. +buzz-test-client = { path = "../buzz-test-client" } +ed25519-dalek = "=3.0.0" +nostr = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +hex = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } diff --git a/crates/buzz-relay/examples/mesh_admission_smoke.rs b/crates/buzz-mesh-smoke/examples/mesh_admission_smoke.rs similarity index 100% rename from crates/buzz-relay/examples/mesh_admission_smoke.rs rename to crates/buzz-mesh-smoke/examples/mesh_admission_smoke.rs diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-mesh-smoke/examples/mesh_agent_e2e.rs similarity index 100% rename from crates/buzz-relay/examples/mesh_agent_e2e.rs rename to crates/buzz-mesh-smoke/examples/mesh_agent_e2e.rs diff --git a/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs b/crates/buzz-mesh-smoke/examples/mesh_relay_lifecycle_smoke.rs similarity index 100% rename from crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs rename to crates/buzz-mesh-smoke/examples/mesh_relay_lifecycle_smoke.rs diff --git a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs b/crates/buzz-mesh-smoke/examples/mesh_serve_client_smoke.rs similarity index 100% rename from crates/buzz-relay/examples/mesh_serve_client_smoke.rs rename to crates/buzz-mesh-smoke/examples/mesh_serve_client_smoke.rs diff --git a/crates/buzz-relay/examples/mesh_serve_smoke.rs b/crates/buzz-mesh-smoke/examples/mesh_serve_smoke.rs similarity index 100% rename from crates/buzz-relay/examples/mesh_serve_smoke.rs rename to crates/buzz-mesh-smoke/examples/mesh_serve_smoke.rs diff --git a/crates/buzz-relay/examples/mesh_stack_smoke.rs b/crates/buzz-mesh-smoke/examples/mesh_stack_smoke.rs similarity index 100% rename from crates/buzz-relay/examples/mesh_stack_smoke.rs rename to crates/buzz-mesh-smoke/examples/mesh_stack_smoke.rs diff --git a/crates/buzz-mesh-smoke/src/lib.rs b/crates/buzz-mesh-smoke/src/lib.rs new file mode 100644 index 0000000000..8d2a277be3 --- /dev/null +++ b/crates/buzz-mesh-smoke/src/lib.rs @@ -0,0 +1,10 @@ +//! Mesh smoke harnesses. +//! +//! This crate deliberately carries no library code. It exists so the +//! mesh-llm SDK and native runtime can be a dev-dependency of the smoke +//! harnesses in `examples/` without entering `buzz-relay`'s test graph, +//! where it added the whole mesh-llm + skippy + rmcp compile to every relay +//! unit test build. +//! +//! See `examples/` for the harnesses and `scripts/ci-mesh-lifecycle-smoke.sh` +//! for the CI entry point. diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index d022bcab01..39f8b26969 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -86,13 +86,10 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } -# Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): -# the relay client for discovery notes and the exact ed25519 the mesh owner -# keys use for binding verification. -buzz-test-client = { path = "../buzz-test-client" } -ed25519-dalek = "=3.0.0-rc.0" +# The mesh-llm smoke harnesses moved to `crates/buzz-mesh-smoke`. A +# dev-dependency feeds every target of its package, so keeping the mesh-llm +# SDK here forced the entire mesh-llm/skippy/rmcp tree to compile before any +# relay unit test could build — the Desktop E2E Relay 30m timeout. buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } diff --git a/crates/buzz-relay/src/api/admin/auth.rs b/crates/buzz-relay/src/api/admin/auth.rs index a260123be2..9c7a9556ae 100644 --- a/crates/buzz-relay/src/api/admin/auth.rs +++ b/crates/buzz-relay/src/api/admin/auth.rs @@ -96,6 +96,15 @@ pub(crate) fn admin_source_str(source: &AdminSource) -> &'static str { } } +/// Compare an inbound Host against the configured admin host case-insensitively. +/// Host names are case-insensitive (RFC 3986 §6.2.2.1), and `config.host` is +/// already lowercased at config load — but a proxy, curl, or non-desktop client +/// can still send a mixed-case Host header, so the comparison itself must fold +/// case rather than relying on the inbound value already being lowercase. +fn host_matches(inbound: &str, configured: &str) -> bool { + inbound.eq_ignore_ascii_case(configured) +} + pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool { let Some(config) = state.config.admin.as_ref() else { return false; @@ -103,7 +112,7 @@ pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool { headers .get(header::HOST) .and_then(|value| value.to_str().ok()) - .is_some_and(|host| host == config.host) + .is_some_and(|host| host_matches(host, &config.host)) } /// Scheme for an admin authority: `http://` for loopback hosts (`localhost`, @@ -443,19 +452,47 @@ fn nostr_credential(value: &str) -> Option<&str> { } fn origin_matches_host(origin: &str, host: &str) -> bool { - // Compare against the exact canonical origin: https:// for non-loopback, - // http:// for loopback. Accepting either scheme for non-loopback would - // allow plaintext origins for production hosts. - let expected = format!("{}://{host}", scheme_for_host(host)); - origin == expected + // The scheme is matched exactly — https:// for non-loopback, http:// for + // loopback. Accepting either scheme for non-loopback would allow plaintext + // origins for production hosts, so the scheme check must not fold anything. + // The host portion, by contrast, is case-insensitive (RFC 3986 §6.2.2.1) + // and may arrive mixed-case from a browser, so it folds case. + let Some(origin_host) = origin + .strip_prefix(scheme_for_host(host)) + .and_then(|rest| rest.strip_prefix("://")) + else { + return false; + }; + origin_host.eq_ignore_ascii_case(host) } #[cfg(test)] mod tests { use super::{ - admin_api_origin, canonical_url, method_has_body, nostr_credential, origin_matches_host, + admin_api_origin, canonical_url, host_matches, method_has_body, nostr_credential, + origin_matches_host, }; + #[test] + fn admin_host_compare_is_case_insensitive() { + // config.host is lowercased at load, but a proxy/curl/non-desktop + // client can still send a mixed-case Host header — it must match. + assert!(host_matches( + "Admin.Example.Com:8443", + "admin.example.com:8443" + )); + // Exact same-case is trivially a match. + assert!(host_matches( + "admin.example.com:8443", + "admin.example.com:8443" + )); + // A genuinely different host never matches. + assert!(!host_matches( + "attacker.example:8443", + "admin.example.com:8443" + )); + } + #[test] fn browser_origin_must_match_admin_host() { assert!(origin_matches_host( @@ -492,6 +529,17 @@ mod tests { "https://admin.localhost:3000", "admin.localhost:3000" )); + // Host is case-insensitive (RFC 3986 §6.2.2.1): a mixed-case Origin + // host matches the lowercased configured host, but the scheme is still + // matched exactly (http rejected for a non-loopback host). + assert!(origin_matches_host( + "https://Admin.Example.Com", + "admin.example.com" + )); + assert!(!origin_matches_host( + "http://Admin.Example.Com", + "admin.example.com" + )); } #[test] diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index a891b282c3..5ca74ec659 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use auth::{ admin_role_str, admin_source_str, authorize, require_mutation_principal, require_operator, - AdminRole, + AdminRole, AdminSource, }; use axum::{ body::Bytes, @@ -208,7 +208,7 @@ async fn reports( .await?; validate( query.status.as_deref(), - &["open", "resolved", "dismissed", "escalated"], + REPORT_STATUS_ALLOWLIST, "invalid_status", )?; validate(query.scope.as_deref(), &["all"], "invalid_scope")?; @@ -436,6 +436,11 @@ struct ResolveReportBody { /// chrono/`i64` overflow range so the computation can never panic. const MAX_TIMEOUT_SECS: u64 = 365 * 24 * 60 * 60; +/// Allowed explicit `status=` values for the `list_reports` endpoint. +/// Mutation: remove "processing" here → `report_status_accepts_processing` goes RED. +const REPORT_STATUS_ALLOWLIST: &[&str] = + &["open", "processing", "resolved", "dismissed", "escalated"]; + /// Convert an attacker-controlled `expiration_secs` into a future timeout /// instant, rejecting zero, the over-cap range, and any value that would /// overflow the timestamp arithmetic. Never panics; never yields a past instant. @@ -992,7 +997,7 @@ async fn upsert_operator( headers: HeaderMap, Path(pubkey_hex): Path, body_bytes: Bytes, -) -> Result, ApiError> { +) -> Result, ApiError> { let principal_opt = authorize( &state, &headers, @@ -1048,9 +1053,16 @@ async fn upsert_operator( _ => ApiError::internal(), })?; - Ok(Json( - serde_json::json!({"pubkey": canonical_hex, "role": body.role}), - )) + // Build the entry from what was just written, in the same shape + // `list_operators` returns (the desktop `AdminOperatorDto`). The 409 guard + // above excludes config-backed keys, so the effective grant is exactly the + // DB row: `body.role` from source `db`. Re-reading the roster here would let + // a concurrent DELETE turn a committed write into a 403. + Ok(Json(OperatorEntry { + pubkey: canonical_hex, + effective_role: body.role, + sources: vec![admin_source_str(&AdminSource::Db).to_string()], + })) } /// DELETE /operators/{pubkey} @@ -1207,21 +1219,39 @@ fn decode_cursor(token: &str) -> Result<(DateTime, Vec), ApiError> { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CommunityQuery { - community_id: Uuid, + community_host: String, +} + +/// Resolve a client-supplied community host to its tenant through the same +/// fail-closed binder that scopes live connections. The client's own local +/// community ids are never trusted; an unmapped host is an error, so a wrong +/// target can never masquerade as an empty result. +async fn community_for_host( + state: &crate::state::AppState, + host: &str, +) -> Result { + match crate::tenant::bind_community(&state.db, host).await { + Ok(tenant) => Ok(tenant.community()), + Err(crate::tenant::BindError::UnmappedHost) => Err(ApiError::bad_request( + "unknown_community_host", + "no community is served at this host", + )), + Err(crate::tenant::BindError::Lookup(_)) => Err(ApiError::internal()), + } } /// Query params for `GET /members/restrictions`. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RestrictionsQuery { - community_id: Uuid, + community_host: String, /// Maximum number of records to return (1–200, default 200). limit: Option, /// Opaque continuation cursor from a prior response's `nextCursor` field. cursor: Option, } -/// GET /members/restrictions?communityId={uuid}[&limit={1-200}][&cursor={token}] +/// GET /members/restrictions?communityHost={host}[&limit={1-200}][&cursor={token}] /// /// List currently active bans and timeouts for the given community, newest /// first, with stable keyset pagination. @@ -1232,8 +1262,9 @@ struct RestrictionsQuery { /// - `cursor` — opaque token from a prior page's `nextCursor`. Omit for the /// first page. Format: base64url of `{updated_at_micros}_{pubkey_hex}`. /// -/// Returns 400 if `communityId` is absent / invalid, `limit` is out of range, -/// or `cursor` is malformed. Returns 401 without a valid admin credential. +/// Returns 400 if `communityHost` is absent or served by no community +/// (`unknown_community_host`), `limit` is out of range, or `cursor` is +/// malformed. Returns 401 without a valid admin credential. async fn list_member_restrictions( State(state): State>, uri: Uri, @@ -1253,7 +1284,7 @@ async fn list_member_restrictions( let page_limit = limit(Some(query.limit.unwrap_or(200)))?; let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?; - let community = buzz_core::CommunityId::from_uuid(query.community_id); + let community = community_for_host(&state, &query.community_host).await?; let records = state .db .list_community_restrictions_page(community, page_limit, cursor) @@ -1273,7 +1304,7 @@ async fn list_member_restrictions( })) } -/// DELETE /members/{pubkey}/ban?communityId={uuid} +/// DELETE /members/{pubkey}/ban?communityHost={host} /// /// Lift an active ban for the given member in the given community. /// Returns 204 on success, 409 if no active ban exists. @@ -1298,7 +1329,7 @@ async fn unban_member( let principal = require_mutation_principal(principal_opt)?; let target_bytes = decode_hex_pubkey(&pubkey_hex)?; - let community = buzz_core::CommunityId::from_uuid(query.community_id); + let community = community_for_host(&state, &query.community_host).await?; let actor_authority = match principal.role { AdminRole::Operator => "relay_operator", @@ -1321,7 +1352,7 @@ async fn unban_member( Ok(axum::http::StatusCode::NO_CONTENT) } -/// DELETE /members/{pubkey}/timeout?communityId={uuid} +/// DELETE /members/{pubkey}/timeout?communityHost={host} /// /// Clear an active timeout/write-block for the given member in the given /// community. Returns 204 on success, 409 if no active timeout exists. @@ -1346,7 +1377,7 @@ async fn untimeout_member( let principal = require_mutation_principal(principal_opt)?; let target_bytes = decode_hex_pubkey(&pubkey_hex)?; - let community = buzz_core::CommunityId::from_uuid(query.community_id); + let community = community_for_host(&state, &query.community_host).await?; let actor_authority = match principal.role { AdminRole::Operator => "relay_operator", @@ -1807,6 +1838,33 @@ mod postgres_tests { assert!(validate(Some("unknown"), &["open"], "invalid_status").is_err()); } + #[test] + fn report_status_accepts_processing() { + // Wes P2 round-6: explicit status=processing must be accepted by the + // allowlist used in list_reports. References the production constant so + // removing "processing" from REPORT_STATUS_ALLOWLIST makes this RED + // while the omitted-default and scope=all tests stay green. + assert!( + validate( + Some("processing"), + REPORT_STATUS_ALLOWLIST, + "invalid_status" + ) + .is_ok(), + "status=processing must be in the production allowlist" + ); + // Confirm the gate still rejects values outside the set. + assert!( + validate( + Some("unknown_state"), + REPORT_STATUS_ALLOWLIST, + "invalid_status" + ) + .is_err(), + "status=unknown_state must be rejected by the production allowlist" + ); + } + #[test] fn feedback_summary_is_unicode_safe_and_marks_truncation() { let body = "🐝".repeat(241); @@ -1922,6 +1980,7 @@ mod postgres_tests { } #[tokio::test] + #[ignore = "requires PostgreSQL"] async fn disabled_mode_allows_unauthenticated_requests_on_the_admin_host() { let state = disabled_mode_state().await; for uri in read_routes() { @@ -1935,7 +1994,7 @@ mod postgres_tests { ) .await; // The routes return 200 (or 404 for unknown resources) — never 401. - // 404 is fine here: there is no real DB, so the row lookups fail. + // 404 is fine for unknown resources; the assertion is only that auth is skipped. assert_ne!( response.status(), StatusCode::UNAUTHORIZED, @@ -2009,11 +2068,10 @@ mod postgres_tests { #[tokio::test] async fn list_restrictions_rejects_missing_credential() { let state = test_state().await; - let community_id = Uuid::nil(); let response = status_for( state, Request::builder() - .uri(format!("/members/restrictions?communityId={community_id}")) + .uri("/members/restrictions?communityHost=unauth.example") .header(header::HOST, "admin.example") .body(Body::empty()) .expect("request"), @@ -2030,13 +2088,12 @@ mod postgres_tests { async fn unban_member_rejects_missing_credential() { let state = test_state().await; let pubkey_hex = "ab".repeat(32); - let community_id = Uuid::nil(); let response = status_for( state, Request::builder() .method("DELETE") .uri(format!( - "/members/{pubkey_hex}/ban?communityId={community_id}" + "/members/{pubkey_hex}/ban?communityHost=unauth.example" )) .header(header::HOST, "admin.example") .body(Body::empty()) @@ -2054,13 +2111,12 @@ mod postgres_tests { async fn untimeout_member_rejects_missing_credential() { let state = test_state().await; let pubkey_hex = "ab".repeat(32); - let community_id = Uuid::nil(); let response = status_for( state, Request::builder() .method("DELETE") .uri(format!( - "/members/{pubkey_hex}/timeout?communityId={community_id}" + "/members/{pubkey_hex}/timeout?communityHost=unauth.example" )) .header(header::HOST, "admin.example") .body(Body::empty()) @@ -2092,9 +2148,7 @@ mod postgres_tests { let state = test_state().await; let operator_keys = test_operator_keys(); let pubkey_hex = "ab".repeat(32); - let community_id = community_uuid; - - let path = format!("/members/{pubkey_hex}/ban?communityId={community_id}"); + let path = format!("/members/{pubkey_hex}/ban?communityHost={host}"); let auth = make_nostr_auth_delete(&operator_keys, &path); let response = status_for( state, @@ -2131,9 +2185,7 @@ mod postgres_tests { let state = test_state().await; let operator_keys = test_operator_keys(); let pubkey_hex = "ab".repeat(32); - let community_id = community_uuid; - - let path = format!("/members/{pubkey_hex}/timeout?communityId={community_id}"); + let path = format!("/members/{pubkey_hex}/timeout?communityHost={host}"); let auth = make_nostr_auth_delete(&operator_keys, &path); let response = status_for( state, @@ -2216,7 +2268,6 @@ mod postgres_tests { .await .expect("create test community") .id; - let community_uuid = *community.as_uuid(); let banned_pubkey = vec![0xAAu8; 32]; let timed_out_pubkey = vec![0xBBu8; 32]; @@ -2237,7 +2288,7 @@ mod postgres_tests { .expect("insert timeout fixture"); let state = nip98_state_with_real_pool(pool).await; - let path = format!("/members/restrictions?communityId={community_uuid}"); + let path = format!("/members/restrictions?communityHost={host}"); let auth = make_nostr_auth(&test_operator_keys(), &path); let response = status_for( state, @@ -2299,6 +2350,57 @@ mod postgres_tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn restrictions_endpoints_reject_an_unknown_host_instead_of_listing_nothing() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let state = nip98_state_with_real_pool(pool).await; + let host = format!("unmapped-{}.example", Uuid::new_v4().simple()); + let target_hex = "ab".repeat(32); + for (method, path) in [ + ("GET", format!("/members/restrictions?communityHost={host}")), + ( + "DELETE", + format!("/members/{target_hex}/ban?communityHost={host}"), + ), + ( + "DELETE", + format!("/members/{target_hex}/timeout?communityHost={host}"), + ), + ] { + let auth = if method == "GET" { + make_nostr_auth(&test_operator_keys(), &path) + } else { + make_nostr_auth_delete(&test_operator_keys(), &path) + }; + let response = status_for( + state.clone(), + Request::builder() + .method(method) + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "{method} {path}" + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + assert!( + String::from_utf8_lossy(&body).contains("unknown_community_host"), + "{method} {path}" + ); + } + } + /// Pagination regression: bind the default=200 cap, SQL LIMIT enforcement, /// keyset continuation, exactly-once coverage, and tie-breaker correctness /// in a single falsifiable route test. @@ -2324,7 +2426,6 @@ mod postgres_tests { .await .expect("create test community") .id; - let community_uuid = *community.as_uuid(); let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); @@ -2400,7 +2501,7 @@ mod postgres_tests { let operator_keys = test_operator_keys(); // ── assertion 1: limit=201 → 400 ───────────────────────────────── - let bad_path = format!("/members/restrictions?communityId={community_uuid}&limit=201"); + let bad_path = format!("/members/restrictions?communityHost={host}&limit=201"); let bad_auth = make_nostr_auth(&operator_keys, &bad_path); let bad_response = status_for( Arc::clone(&state), @@ -2421,7 +2522,7 @@ mod postgres_tests { // ── assertion 2: default limit → exactly 200 items + non-null cursor ─ // (This is the falsifiable binding of default=200 and max=200.) - let first_path = format!("/members/restrictions?communityId={community_uuid}"); + let first_path = format!("/members/restrictions?communityHost={host}"); let first_auth = make_nostr_auth(&operator_keys, &first_path); let first_response = status_for( Arc::clone(&state), @@ -2471,7 +2572,7 @@ mod postgres_tests { let mut page_count = 1usize; // already consumed first page above while let Some(tok) = cursor_token.clone() { - let path = format!("/members/restrictions?communityId={community_uuid}&cursor={tok}"); + let path = format!("/members/restrictions?communityHost={host}&cursor={tok}"); let auth = make_nostr_auth(&operator_keys, &path); let response = status_for( Arc::clone(&state), @@ -2538,7 +2639,6 @@ mod postgres_tests { .await .expect("create other community") .id; - let community_uuid = *community.as_uuid(); // Insert a permanent ban as the target member. let target_pubkey = vec![0xCCu8; 32]; @@ -2574,7 +2674,7 @@ mod postgres_tests { let state = nip98_state_with_real_pool(pool.clone()).await; let target_hex = hex::encode(&target_pubkey); - let path = format!("/members/{target_hex}/ban?communityId={community_uuid}"); + let path = format!("/members/{target_hex}/ban?communityHost={host}"); let auth = make_nostr_auth_delete(&test_operator_keys(), &path); let response = status_for( state, @@ -2663,7 +2763,6 @@ mod postgres_tests { .await .expect("create test community") .id; - let community_uuid = *community.as_uuid(); let target_pubkey = vec![0xDDu8; 32]; let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); @@ -2704,7 +2803,7 @@ mod postgres_tests { let state = nip98_state_with_real_pool(pool.clone()).await; let target_hex = hex::encode(&target_pubkey); - let path = format!("/members/{target_hex}/timeout?communityId={community_uuid}"); + let path = format!("/members/{target_hex}/timeout?communityHost={host}"); let auth = make_nostr_auth_delete(&test_operator_keys(), &path); let response = status_for( state, @@ -2801,7 +2900,6 @@ mod postgres_tests { .await .expect("create test community") .id; - let community_uuid = *community.as_uuid(); // Insert a ban that already expired. let target_pubkey = vec![0xEEu8; 32]; @@ -2819,7 +2917,7 @@ mod postgres_tests { let state = nip98_state_with_real_pool(pool.clone()).await; let target_hex = hex::encode(&target_pubkey); - let path = format!("/members/{target_hex}/ban?communityId={community_uuid}"); + let path = format!("/members/{target_hex}/ban?communityHost={host}"); let auth = make_nostr_auth_delete(&test_operator_keys(), &path); let response = status_for( state, @@ -5369,6 +5467,95 @@ mod postgres_tests { assert_eq!(remaining, 0, "the canonical row must be removed"); } + /// Contract seam: PUT /operators/{pubkey} must return the effective + /// `OperatorEntry` (camelCase `effectiveRole` + `sources`), not a bare + /// `{pubkey, role}` — the desktop types the result as `AdminOperatorDto`. + /// Exercises the real HTTP handler so a regression to inline `json!` would + /// drop `effectiveRole`/`sources` and fail here. The uppercase-path PUT pins + /// that the echoed pubkey is canonicalized to lowercase. + #[tokio::test] + #[ignore = "requires Postgres — PUT /operators returns the effective OperatorEntry"] + async fn upsert_operator_returns_effective_operator_entry() { + let operator_keys = nostr::Keys::generate(); + let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; + + let target_keys = nostr::Keys::generate(); + let lower_hex = target_keys.public_key().to_hex(); + + // PUT a moderator grant on a fresh, non-config key. + let path = format!("/operators/{lower_hex}"); + let put_body = r#"{"role":"moderator"}"#.as_bytes(); + let put = status_for( + state.clone(), + Request::builder() + .method("PUT") + .uri(&path) + .header(header::HOST, "admin.example") + .header( + header::AUTHORIZATION, + make_nostr_auth_put(&operator_keys, &path, put_body), + ) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(put_body.to_vec())) + .expect("request"), + ) + .await; + assert_eq!(put.status(), StatusCode::OK, "grant PUT must succeed"); + let put_json: serde_json::Value = { + let bytes = axum::body::to_bytes(put.into_body(), 4096) + .await + .expect("body"); + serde_json::from_slice(&bytes).expect("json") + }; + assert_eq!( + put_json["pubkey"], lower_hex, + "response echoes the canonical lowercase pubkey" + ); + assert_eq!( + put_json["effectiveRole"], "moderator", + "response carries the effective role" + ); + assert_eq!( + put_json["sources"], + serde_json::json!(["db"]), + "a non-config grant resolves to the db source only" + ); + + // Idempotent re-PUT through an uppercase path: the echoed pubkey must + // still be lowercased even though the path param is uppercase. + let upper_path = format!("/operators/{}", lower_hex.to_ascii_uppercase()); + let upper = status_for( + state, + Request::builder() + .method("PUT") + .uri(&upper_path) + .header(header::HOST, "admin.example") + .header( + header::AUTHORIZATION, + make_nostr_auth_put(&operator_keys, &upper_path, put_body), + ) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(put_body.to_vec())) + .expect("request"), + ) + .await; + assert_eq!( + upper.status(), + StatusCode::OK, + "uppercase-path PUT must succeed" + ); + let upper_json: serde_json::Value = { + let bytes = axum::body::to_bytes(upper.into_body(), 4096) + .await + .expect("body"); + serde_json::from_slice(&bytes).expect("json") + }; + assert_eq!( + upper_json["pubkey"], lower_hex, + "uppercase path param must be canonicalized to lowercase in the response" + ); + } + #[tokio::test] #[ignore = "requires Postgres — reopen of an open report is 409"] async fn reopen_route_rejects_non_terminal_report_with_409() { @@ -5951,6 +6138,7 @@ mod postgres_tests { reporter_pubkey: "0".repeat(64), target_kind: "pubkey".to_string(), target: hex::encode(target), + target_author_pubkey: None, channel_id: None, report_type: "harassment".to_string(), note: None, diff --git a/crates/buzz-relay/src/handlers/report_resolution.rs b/crates/buzz-relay/src/handlers/report_resolution.rs index 8b77eb7746..a5253c79fa 100644 --- a/crates/buzz-relay/src/handlers/report_resolution.rs +++ b/crates/buzz-relay/src/handlers/report_resolution.rs @@ -522,7 +522,8 @@ async fn drive_enforcement( target_event_id, channel_id, }; - let mutation_result = run_atomic_mutation(state, action_id, lease_token, &ctx).await; + let mutation_result = + run_atomic_mutation(state, tenant, action_id, lease_token, &ctx).await; // On enforcement error, record the failure while we STILL hold the // lease — `record_action_failure` is fenced on the live token, so it @@ -732,6 +733,7 @@ enum MutationOutcome { /// - `Err` — the mutation itself failed (DB or validation error). async fn run_atomic_mutation( state: &Arc, + tenant: &TenantContext, action_id: Uuid, lease_token: Uuid, ctx: &EnforcementCtx<'_>, @@ -816,7 +818,7 @@ async fn run_atomic_mutation( .map_err(|e| anyhow::anyhow!("thread metadata lookup failed: {e}"))?; let parent_id = meta.as_ref().and_then(|m| m.parent_event_id.clone()); let root_id = meta.as_ref().and_then(|m| m.root_event_id.clone()); - state + let committed = state .db .execute_delete_with_marker( action_id, @@ -827,7 +829,18 @@ async fn run_atomic_mutation( root_id.as_deref(), ) .await - .map_err(|e| anyhow::anyhow!("delete failed: {e}")) + .map_err(|e| anyhow::anyhow!("delete failed: {e}"))?; + // Same post-commit refresh as NIP-29 DELETE_EVENT: push a fresh + // 39005 so live badge counts also count down. + if let (true, Some(meta), Some(root_id)) = (committed, meta, root_id) { + crate::handlers::side_effects::emit_live_thread_summary( + tenant, + state, + meta.channel_id, + root_id, + ); + } + Ok(committed) } other => Err(anyhow::anyhow!("unexpected enforcement action: {other}")), }; @@ -981,6 +994,7 @@ mod tests { reporter_pubkey: "0".repeat(64), target_kind: target_kind.to_string(), target: target.to_string(), + target_author_pubkey: None, channel_id: None, report_type: "spam".to_string(), note: None, diff --git a/deploy/charts/buzz/ci/quickstart-values.yaml b/deploy/charts/buzz/ci/quickstart-values.yaml index 4dcf6bcd21..1a7f4d5838 100644 --- a/deploy/charts/buzz/ci/quickstart-values.yaml +++ b/deploy/charts/buzz/ci/quickstart-values.yaml @@ -9,6 +9,8 @@ redis: enabled: true minio: enabled: true + image: ghcr.io/block/buzz-minio:latest + mcImage: ghcr.io/block/buzz-minio:latest relayUrl: wss://buzz.test.local ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001" relay: diff --git a/desktop/package.json b/desktop/package.json index 6b2834fe6b..154e3bae1d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.24", + "version": "0.5.25", "type": "module", "scripts": { "dev": "vite", @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\" && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test \"src/**/*.jsdom-test.mjs\"", "preview": "vite preview", "tauri": "node ./scripts/tauri-command.mjs", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index 67ce2a9091..6efec92978 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -30,6 +30,8 @@ const overrides = new Set([ "src/features/messages/lib/threadPanel.ts:395", "src/features/projects/ui/ProjectsView.tsx:166", "src/features/projects/ui/ProjectsOverviewPanel.tsx:209", + // Error message prefix in a console-internal action error (never rendered as identity). + "src/features/admin-console/AdminConsoleStaffingTab.tsx:108", ]); await runPubkeyTruncationCheck({ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0bc8293553..9568b6083e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -39,6 +39,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -67,7 +77,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", + "aead 0.5.2", "aes 0.8.4", "cipher 0.4.4", "ctr", @@ -114,7 +124,7 @@ dependencies = [ "piper", "polling", "regex-automata", - "rustix 1.1.4", + "rustix", "rustix-openpty", "signal-hook 0.4.4", "unicode-width 0.2.2", @@ -230,6 +240,17 @@ 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 3.7.0", +] + [[package]] name = "approx" version = "0.5.1" @@ -280,14 +301,14 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", - "password-hash", + "cpufeatures 0.3.0", + "password-hash 0.6.1", ] [[package]] @@ -353,7 +374,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -384,7 +405,7 @@ dependencies = [ "cfg-if 1.0.4", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -410,7 +431,7 @@ dependencies = [ "cfg-if 1.0.4", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -745,6 +766,12 @@ 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" @@ -883,11 +910,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -932,6 +959,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -1046,7 +1082,7 @@ dependencies = [ "hex", "nix 0.31.3", "reqwest 0.13.4", - "rmcp", + "rmcp 1.8.0", "serde", "serde_json", "serde_yaml", @@ -1082,7 +1118,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.24" +version = "0.5.25" dependencies = [ "anyhow", "arboard", @@ -1113,7 +1149,7 @@ dependencies = [ "image", "infer", "iroh", - "keyring", + "keyring 3.6.3", "libc", "mesh-llm-client", "mesh-llm-events", @@ -1417,6 +1453,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.2.66" @@ -1498,6 +1543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if 1.0.4", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -1508,13 +1554,25 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", "cipher 0.4.4", - "poly1305", + "poly1305 0.8.0", "zeroize", ] +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20 0.10.1", + "cipher 0.5.2", + "poly1305 0.9.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1546,6 +1604,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", ] @@ -1955,22 +2014,6 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags 2.13.0", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook 0.3.18", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.29.0" @@ -1983,7 +2026,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix 1.1.4", + "rustix", "signal-hook 0.3.18", "signal-hook-mio", "winapi", @@ -2021,7 +2064,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -2030,7 +2075,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009" dependencies = [ - "aead", + "aead 0.5.2", "crypto_secretbox", "curve25519-dalek 4.1.3", "salsa20", @@ -2044,10 +2089,10 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", "generic-array", - "poly1305", + "poly1305 0.8.0", "salsa20", "subtle", "zeroize", @@ -2174,9 +2219,9 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "5.0.0-rc.0" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.3.0", @@ -2221,6 +2266,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2248,6 +2303,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -2270,6 +2338,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "dary_heap" version = "0.3.9" @@ -2329,11 +2408,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ "aes 0.8.4", - "block-padding", - "cbc", + "block-padding 0.3.3", + "cbc 0.1.2", "dbus", "fastrand", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", "openssl", @@ -2644,11 +2723,11 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "3.0.0-rc.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek 5.0.0-rc.0", + "curve25519-dalek 5.0.0", "ed25519", "rand_core 0.10.1", "serde", @@ -2968,9 +3047,9 @@ dependencies = [ [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -3316,7 +3395,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix", "windows-link 0.2.1", ] @@ -3659,6 +3738,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -3674,11 +3755,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.17.1", ] [[package]] @@ -3857,6 +3938,15 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -4005,22 +4095,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -4307,7 +4381,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] @@ -4317,6 +4391,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding 0.4.2", "hybrid-array", ] @@ -4424,7 +4499,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" dependencies = [ - "curve25519-dalek 5.0.0-rc.0", + "curve25519-dalek 5.0.0", "data-encoding", "data-encoding-macro", "derive_more", @@ -4745,13 +4820,34 @@ dependencies = [ "dbus-secret-service", "log", "openssl", - "secret-service", + "secret-service 4.0.0", "security-framework 2.11.1", "security-framework 3.7.0", "windows-sys 0.60.2", "zeroize", ] +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +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 = "konst" version = "0.4.3" @@ -4813,9 +4909,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -4839,9 +4935,9 @@ dependencies = [ [[package]] name = "libloading" -version = "0.8.9" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if 1.0.4", "windows-link 0.2.1", @@ -4864,9 +4960,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -4894,12 +4990,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -5140,9 +5230,9 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "mdns-sd" -version = "0.19.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18148fee27e99e76dbf6e137f27727113d31f766e578d1b93a93c3615fca7081" +checksum = "a63c9b854b5ad0812ac5969f8db92a59f28b45d0a5ae599b9a45b84c5c8a08e8" dependencies = [ "fastrand", "flume", @@ -5159,6 +5249,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memmem" version = "0.1.1" @@ -5176,8 +5275,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "hex", "mesh-llm-client", @@ -5186,8 +5285,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5197,17 +5296,17 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" [[package]] name = "mesh-llm-client" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.23.1", "bytes", "httparse", "iroh", @@ -5229,24 +5328,25 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "dirs", + "mesh-llm-native-runtime", "mesh-llm-types", "semver", "serde", "skippy-protocol", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "toml_edit 0.25.12+spec-1.1.0", "url", ] [[package]] name = "mesh-llm-embedded-runtime" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5255,13 +5355,13 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "chrono", "clap", - "crossterm 0.28.1", + "crossterm", "libc", "ratatui", "serde", @@ -5272,8 +5372,8 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", "serde_json", @@ -5282,8 +5382,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", "serde_json", @@ -5291,8 +5391,8 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "mesh-llm-native-runtime", ] @@ -5328,19 +5428,19 @@ dependencies = [ [[package]] name = "mesh-llm-host-runtime" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "argon2", "async-trait", "axum", - "base64 0.22.1", + "base64 0.23.1", "bytes", - "chacha20poly1305", + "chacha20poly1305 0.11.0", "chrono", "clap", - "crossterm 0.28.1", + "crossterm", "crypto_box", "dirs", "ed25519-dalek", @@ -5351,7 +5451,7 @@ dependencies = [ "http-body-util", "httparse", "iroh", - "keyring", + "keyring 4.2.0", "libc", "mdns-sd", "mesh-llm-api-server", @@ -5388,17 +5488,20 @@ dependencies = [ "prost 0.14.4", "rand 0.10.2", "regex-lite", - "reqwest 0.12.28", - "rmcp", + "reqwest 0.13.4", + "rmcp 3.2.0", "rpassword", "rustls", + "same-file", "schemars 1.2.1", "semver", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-coordinator", "skippy-ffi", + "skippy-model", + "skippy-package-format", "skippy-protocol", "skippy-runtime", "skippy-server", @@ -5410,7 +5513,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "tracing", "tracing-subscriber", "url", @@ -5418,35 +5521,36 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "zeroize", - "zip 2.4.2", + "zip 8.6.0", ] [[package]] name = "mesh-llm-identity" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ + "apple-native-keyring-store", "argon2", - "base64 0.22.1", - "chacha20poly1305", + "base64 0.23.1", + "chacha20poly1305 0.11.0", "chrono", "crypto_box", "dirs", "ed25519-dalek", "hex", - "keyring", + "keyring 4.2.0", "rand 0.10.2", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "thiserror 2.0.18", "zeroize", ] [[package]] name = "mesh-llm-log-store" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "chrono", "data-encoding", @@ -5455,7 +5559,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "thiserror 2.0.18", "uuid", "windows-sys 0.61.2", @@ -5463,19 +5567,20 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", + "hex", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "mesh-llm-node" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "mesh-llm-types", @@ -5488,15 +5593,15 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "async-trait", "prost 0.14.4", "prost-build 0.14.4", "protoc-bin-vendored", - "rmcp", + "rmcp 3.2.0", "schemars 1.2.1", "serde", "serde_json", @@ -5505,49 +5610,50 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "dirs", "flate2", "futures-util", + "hex", "mesh-llm-skills", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tar", "tempfile", - "zip 2.4.2", + "zip 8.6.0", ] [[package]] name = "mesh-llm-protocol" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "hex", "iroh", "prost 0.14.4", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "mesh-llm-release-footer" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "hex", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "mesh-llm-routing" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "blake3", "iroh", @@ -5557,8 +5663,8 @@ dependencies = [ [[package]] name = "mesh-llm-runtime-install" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "dirs", @@ -5568,10 +5674,10 @@ dependencies = [ "mesh-llm-build-info", "mesh-llm-hardware-profile", "mesh-llm-native-runtime", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-ffi", "tar", "tempfile", @@ -5580,23 +5686,23 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "mesh-llm-api-client", "mesh-llm-api-server", "mesh-llm-embedded-runtime", "mesh-llm-runtime-install", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", ] [[package]] name = "mesh-llm-skills" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "dirs", @@ -5606,8 +5712,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "chrono", @@ -5615,46 +5721,46 @@ dependencies = [ "dirs", "hex", "libc", - "libloading 0.8.9", + "libloading 0.9.0", "mesh-llm-build-info", "mesh-llm-gpu-bench", "mesh-llm-native-runtime", "mesh-llm-release-footer", "mesh-llm-runtime-install", - "reqwest 0.12.28", + "reqwest 0.13.4", "semver", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-runtime", "tracing", - "zip 2.4.2", + "zip 8.6.0", ] [[package]] name = "mesh-llm-types" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "hex", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "mesh-llm-ui" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" [[package]] name = "mesh-mixture-of-agents" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "async-trait", "mesh-llm-guardrails", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -5663,16 +5769,16 @@ dependencies = [ [[package]] name = "mesh-native-serving-plugin-api" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" [[package]] name = "mesh-native-serving-plugin-host" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", - "libloading 0.8.9", + "libloading 0.9.0", "mesh-native-serving-plugin-api", "skippy-server", "skippy-tokenizer", @@ -5770,8 +5876,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "async-trait", @@ -5781,13 +5887,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", + "hex", "libc", "mesh-llm-hf-hub", "model-artifact", @@ -5795,15 +5902,15 @@ dependencies = [ "rustls", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tokio", "tracing", ] [[package]] name = "model-package" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "bytes", @@ -5812,26 +5919,26 @@ dependencies = [ "mesh-llm-hf-hub", "model-hf", "model-ref", - "reqwest 0.12.28", + "reqwest 0.13.4", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tempfile", "tokio", ] [[package]] name = "model-ref" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "model-artifact", @@ -5989,23 +6096,6 @@ dependencies = [ "n0-future", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 3.7.0", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndarray" version = "0.17.2" @@ -6329,9 +6419,9 @@ dependencies = [ "bech32 0.11.1", "bip39", "bitcoin_hashes 0.14.101", - "cbc", + "cbc 0.1.2", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "getrandom 0.2.17", "hex", "instant", @@ -6353,9 +6443,9 @@ dependencies = [ "bech32 0.12.0", "bip39", "bitcoin_hashes 1.2.0", - "cbc", + "cbc 0.1.2", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "faster-hex", "opaquerr", "rand 0.10.2", @@ -6550,7 +6640,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -6932,8 +7022,8 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "async-trait", "axum", @@ -7270,6 +7360,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -7396,6 +7496,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.11.3" @@ -7603,7 +7714,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -7615,7 +7726,17 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -7627,7 +7748,7 @@ dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -8344,7 +8465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if 1.0.4", - "crossterm 0.29.0", + "crossterm", "instability", "ratatui-core", ] @@ -8544,21 +8665,16 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -8569,7 +8685,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -8703,13 +8818,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", - "base64 0.22.1", + "chrono", + "futures", + "pin-project-lite", + "process-wrap", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5" +dependencies = [ + "async-trait", + "base64 0.23.1", "bytes", "chrono", "futures", "http", "http-body", "http-body-util", + "indexmap 2.14.0", "pastey", "pin-project-lite", "process-wrap", @@ -8731,15 +8867,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.8.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", "serde_json", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -8769,6 +8905,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rtoolbox" version = "0.0.6" @@ -8803,9 +8949,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.37.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.0", "fallible-iterator", @@ -8813,6 +8959,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -8889,19 +9036,6 @@ dependencies = [ "transpose", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -8911,7 +9045,7 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -8923,7 +9057,7 @@ checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" dependencies = [ "errno", "libc", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -9040,6 +9174,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" +[[package]] +name = "safetensors" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +dependencies = [ + "hashbrown 0.16.1", + "libc", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "salsa20" version = "0.10.2" @@ -9150,7 +9297,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "password-hash", + "password-hash 0.5.0", "pbkdf2 0.12.2", "salsa20", "sha2 0.10.9", @@ -9194,10 +9341,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" dependencies = [ "aes 0.8.4", - "cbc", + "cbc 0.1.2", "futures-util", "generic-array", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", "rand 0.8.6", @@ -9206,6 +9353,25 @@ dependencies = [ "zbus 4.4.0", ] +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes 0.9.1", + "cbc 0.2.1", + "futures-util", + "getrandom 0.4.3", + "hkdf 0.13.0", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.0", + "zbus 5.17.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -9694,6 +9860,9 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "simd-adler32" @@ -9734,8 +9903,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", "blake3", @@ -9744,29 +9913,53 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ - "libloading 0.8.9", + "libloading 0.9.0", ] [[package]] name = "skippy-metrics" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" + +[[package]] +name = "skippy-model" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" +dependencies = [ + "anyhow", + "clap", + "memmap2", + "safetensors", + "serde", + "serde_json", + "skippy-package-format", +] + +[[package]] +name = "skippy-package-format" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" +dependencies = [ + "serde", + "serde_json", + "sha2 0.11.0", +] [[package]] name = "skippy-protocol" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "prost 0.14.4", "prost-build 0.14.4", @@ -9777,22 +9970,25 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "anyhow", + "hex", "libc", + "model-ref", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-ffi", + "skippy-model", "tokio", ] [[package]] name = "skippy-scheduler" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "skippy-runtime", "thiserror 2.0.18", @@ -9800,14 +9996,14 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "ahash", "anyhow", "async-trait", "axum", - "base64 0.22.1", + "base64 0.23.1", "blake3", "clap", "futures-util", @@ -9819,7 +10015,7 @@ dependencies = [ "opentelemetry-proto", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "skippy-cache", "skippy-metrics", "skippy-protocol", @@ -9830,21 +10026,22 @@ dependencies = [ "socket2", "tokio", "tonic", + "tracing", "uuid", ] [[package]] name = "skippy-tokenizer" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", ] [[package]] name = "skippy-topology" -version = "0.76.0-rc9" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.0-rc9#9f192c9d821991ba43d79bb15fd2f2f12e597571" +version = "0.76.2" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.76.2#a0c1e66b0ac037dd56544b9e2d94969ea43d694f" dependencies = [ "serde", "serde_json", @@ -9864,13 +10061,13 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket-pktinfo" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" dependencies = [ "libc", "socket2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9985,6 +10182,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "sse-stream" version = "0.2.4" @@ -10907,7 +11116,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -10928,7 +11137,7 @@ checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ "bitflags 2.13.0", "parking_lot", - "rustix 1.1.4", + "rustix", "signal-hook 0.3.18", "windows-sys 0.61.2", ] @@ -11196,16 +11405,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-retry" version = "0.3.2" @@ -11914,6 +12113,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "universal-time" version = "0.3.1" @@ -12251,7 +12460,7 @@ checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", + "rustix", "smallvec", "wayland-sys", ] @@ -12263,7 +12472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.0", - "rustix 1.1.4", + "rustix", "wayland-backend", "wayland-scanner", ] @@ -12745,6 +12954,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -13205,7 +13427,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.4", + "rustix", "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", @@ -13326,7 +13548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.4", + "rustix", "x11rb-protocol", ] @@ -13343,7 +13565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -13599,7 +13821,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix", "serde", "serde_repr", "tracing", @@ -13612,6 +13834,17 @@ dependencies = [ "zvariant 5.13.0", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service 5.2.0", + "zbus 5.17.0", +] + [[package]] name = "zbus_macros" version = "4.4.0" @@ -13756,23 +13989,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap 2.14.0", - "memchr", - "thiserror 2.0.18", - "zopfli", -] - [[package]] name = "zip" version = "4.6.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 97cab2907d..6205f3ea65 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.24" +version = "0.5.25" description = "Buzz desktop app" authors = ["you"] edition = "2021" @@ -41,7 +41,13 @@ libc = "0.2" ctrlc = { version = "3", features = ["termination"] } [target.'cfg(target_os = "linux")'.dependencies] -keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true } +# `crypto-rust` is what selects `EncryptionType::Dh` over `Plain` for the +# Secret Service session (keyring-3.6.3/src/secret_service.rs:140-143,333-336), +# i.e. Diffie-Hellman transit encryption of secrets over the D-Bus session bus. +# Until MeshLLM 0.76 it arrived implicitly via feature unification with +# mesh-llm's own keyring 3; mesh-llm now uses keyring 4, so request it here +# explicitly rather than inheriting it by accident. +keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "crypto-rust", "vendored"], optional = true } # Used directly (alongside tauri-plugin-notification) so we can hold the posting # D-Bus connection open. GNOME 46+ dismisses a notification the moment that # connection is dropped, which the plugin does immediately. Default features @@ -83,7 +89,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" infer = "0.19" hex = "0.4" -ed25519-dalek = "=3.0.0-rc.0" +ed25519-dalek = "=3.0.0" tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "net", "io-util"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["rt"] } @@ -113,14 +119,14 @@ buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } buzz_ws_client_pkg = { package = "buzz-ws-client", path = "../../crates/buzz-ws-client" } portable-pty = "0.9" iroh = { version = "1.0.3", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.2", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.2", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.2", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.2", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.2", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.2", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" @@ -140,7 +146,8 @@ zip = "8" flate2 = "1" sherpa-onnx = "1.12" regex = "1" -rusqlite = { version = "0.37", features = ["bundled"] } +# Match MeshLLM's SQLite ABI; keep u64 conversions available without mesh-llm. +rusqlite = { version = "0.40", features = ["bundled", "fallible_uint"] } axum = "0.8" rodio = "0.22" earshot = "1.0" diff --git a/desktop/src-tauri/src/commands/admin/attachment.rs b/desktop/src-tauri/src/commands/admin/attachment.rs new file mode 100644 index 0000000000..5f61d22941 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/attachment.rs @@ -0,0 +1,265 @@ +//! Feedback attachment fetch (one validated path shared by preview and save) +//! and the native save flow. Only the save dialog is injected, so tests drive +//! the real fetch and the real disk write without a window. + +use std::future::Future; +use std::path::PathBuf; + +use super::helpers::{finish_attachment_response, AttachmentUse}; +use super::{client, origin, routes, ATTACHMENT_CAP}; + +/// Fetch a feedback attachment, enforcing the caller's server-validated +/// `imeta` MIME and size: inputs are checked before any network activity, the +/// relay's `Content-Type` must match (Save also accepts `application/octet-stream`, see [`AttachmentUse`]), and the body is streamed under the cap +/// and must equal `expected_size`. Errors are stable `admin_attachment_*` codes. +pub(super) async fn fetch_feedback_attachment( + origin: &str, + feedback_id: &str, + sha256: &str, + expected_mime: &str, + expected_size: u64, + keys: &nostr::Keys, + purpose: AttachmentUse, +) -> Result, String> { + use crate::relay::build_nip98_auth_header_for_keys; + + let feedback_id = uuid::Uuid::parse_str(feedback_id) + .map_err(|_| "admin_attachment_invalid_feedback_id".to_string())?; + let sha256 = routes::AttachmentHash::parse(sha256) + .map_err(|_| "admin_attachment_invalid_hash".to_string())?; + if expected_size == 0 { + return Err("admin_attachment_invalid_size".to_string()); + } + if expected_size > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + if expected_mime.is_empty() { + return Err("admin_attachment_invalid_mime".to_string()); + } + + let origin = origin::AdminOrigin::parse(origin)?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackAttachment { + id: feedback_id, + sha256, + }, + &routes::AdminQuery::default(), + ); + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + // One retry on 401 with a fresh NIP-98 event. + let mut resp = None; + for _ in 0..2 { + let auth = build_nip98_auth_header_for_keys(keys, &reqwest::Method::GET, &url, &[]) + .map_err(|e| format!("nip98 build failed: {e}"))?; + let r = http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, auth) + .send() + .await + .map_err(|e| { + tracing::debug!(error = %e, "admin attachment fetch failed"); + "admin_attachment_network_error".to_string() + })?; + let unauthorized = r.status() == reqwest::StatusCode::UNAUTHORIZED; + resp = Some(r); + if !unauthorized { + break; + } + } + let resp = resp.expect("loop runs at least once"); + finish_attachment_response(resp, expected_mime, expected_size, purpose).await +} + +/// Suggested filename and dialog-filter extension for an attachment, e.g. +/// `("attachment-a1b2c3d4.docx", "docx")`. Unknown MIME types save as `.bin`. +pub(super) fn attachment_file_name(sha256: &str, mime: &str) -> (String, &'static str) { + let mime = mime.trim().to_ascii_lowercase(); + let ext = match mime.as_str() { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + "video/mp4" => "mp4", + "video/quicktime" => "mov", + "video/webm" => "webm", + "audio/mpeg" => "mp3", + "audio/ogg" => "ogg", + "audio/wav" => "wav", + "application/pdf" => "pdf", + "application/msword" => "doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx", + "application/vnd.ms-excel" => "xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx", + "application/vnd.ms-powerpoint" => "ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx", + "application/zip" => "zip", + "application/gzip" => "gz", + "application/json" => "json", + "text/plain" => "txt", + "text/csv" => "csv", + "text/markdown" => "md", + _ => "bin", + }; + let prefix: String = sha256.chars().take(8).collect(); + (format!("attachment-{prefix}.{ext}"), ext) +} + +/// Native Save core: fetch the attachment through the shared validated path, +/// then run [`save_attachment`]. The Tauri command only supplies the dialog. +#[allow(clippy::too_many_arguments)] +pub(super) async fn save_feedback_attachment( + origin: &str, + feedback_id: &str, + sha256: &str, + expected_mime: &str, + expected_size: u64, + keys: &nostr::Keys, + pick: Pick, +) -> Result +where + Pick: FnOnce(String, &'static str, &'static str) -> PickFut, + PickFut: Future, String>>, +{ + save_attachment( + fetch_feedback_attachment( + origin, + feedback_id, + sha256, + expected_mime, + expected_size, + keys, + AttachmentUse::Save, + ), + sha256, + expected_mime, + pick, + ) + .await +} + +/// Fetch, ask the user where to save, and write the bytes to the chosen path. +/// `Ok(false)` means the user cancelled; the fetch completes (and any fetch +/// error surfaces) before the dialog opens, so a failed download never prompts +/// for a path. Only the fetch and the dialog are injected; the disk write is +/// the real one so tests exercise the production sink. +pub(super) async fn save_attachment( + fetch: impl Future, String>>, + sha256: &str, + mime: &str, + pick: Pick, +) -> Result +where + Pick: FnOnce(String, &'static str, &'static str) -> PickFut, + PickFut: Future, String>>, +{ + let bytes = fetch.await?; + let (suggested, ext) = attachment_file_name(sha256, mime); + let filter = if mime.starts_with("image/") { + "Images" + } else { + "Files" + }; + let Some(dest) = pick(suggested, filter, ext).await? else { + return Ok(false); + }; + std::fs::write(&dest, &bytes).map_err(|e| format!("Failed to write file: {e}"))?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + const SHA: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"; + + #[test] + fn file_name_uses_canonical_extensions() { + let docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + for (mime, want) in [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("application/gzip", "gz"), + ("application/octet-stream", "bin"), + (docx, "docx"), + ("application/pdf", "pdf"), + ("Text/Plain", "txt"), + ("application/x-unknown", "bin"), + ] { + let (name, ext) = attachment_file_name(SHA, mime); + assert_eq!(ext, want, "{mime}"); + assert_eq!(name, format!("attachment-a1b2c3d4.{want}")); + } + } + + #[tokio::test] + async fn save_writes_fetched_bytes_to_the_chosen_path() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("report.zip"); + let offered = RefCell::new(None); + let saved = save_attachment( + async { Ok(b"PK-bytes".to_vec()) }, + SHA, + "application/zip", + |name, filter, ext| { + *offered.borrow_mut() = Some((name, filter, ext)); + let dest = dest.clone(); + async move { Ok(Some(dest)) } + }, + ) + .await; + assert_eq!(saved, Ok(true)); + assert_eq!( + offered.into_inner(), + Some(("attachment-a1b2c3d4.zip".to_owned(), "Files", "zip")) + ); + assert_eq!(std::fs::read(&dest).unwrap(), b"PK-bytes"); + } + + #[tokio::test] + async fn cancelled_dialog_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let saved = save_attachment( + async { Ok(vec![1]) }, + SHA, + "application/pdf", + |_, _, _| async { Ok(None) }, + ) + .await; + assert_eq!(saved, Ok(false)); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[tokio::test] + async fn fetch_error_surfaces_before_the_dialog_opens() { + let saved = save_attachment( + async { Err("admin_attachment_mime_mismatch".to_string()) }, + SHA, + "application/pdf", + |_, _, _| -> std::future::Ready, String>> { + panic!("must not prompt after a failed fetch") + }, + ) + .await; + assert_eq!(saved, Err("admin_attachment_mime_mismatch".to_string())); + } + + #[tokio::test] + async fn write_error_is_reported() { + // A path under a missing directory makes the real write fail. + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("missing").join("x.pdf"); + let saved = save_attachment( + async { Ok(vec![1]) }, + SHA, + "application/pdf", + |_, _, _| async move { Ok(Some(dest)) }, + ) + .await; + let err = saved.unwrap_err(); + assert!(err.starts_with("Failed to write file: "), "{err}"); + } +} diff --git a/desktop/src-tauri/src/commands/admin/client.rs b/desktop/src-tauri/src/commands/admin/client.rs new file mode 100644 index 0000000000..828f029e00 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/client.rs @@ -0,0 +1,127 @@ +//! Dedicated no-redirect HTTP client for admin API requests. +//! +//! A separate client (not the app-wide `http_client`) ensures that: +//! - 3xx responses are surfaced as errors rather than followed — preventing +//! redirect-hop SSRF where a relay-issued redirect could forward the NIP-98 +//! `Authorization` header to an off-origin host. +//! - Timeouts are tuned for synchronous UI feedback rather than media downloads. + +use std::sync::OnceLock; + +use super::dns::LocalhostDnsResolver; + +/// Request timeout for admin API calls. +pub(crate) const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// The module-level singleton admin HTTP client. +/// +/// Built once via `OnceLock`. On build failure no client is stored: admin +/// commands then hit the "admin client not initialised" guard rather than ever +/// falling back to a redirect-following client. +pub static ADMIN_CLIENT: OnceLock = OnceLock::new(); + +/// Initialise the admin client singleton. Must be called from `setup()` before +/// any admin command can be invoked. Subsequent calls are no-ops. +/// +/// Returns `Err` if the client fails to build. The caller must propagate this +/// so setup aborts — a redirect-following fallback would forward the NIP-98 +/// `Authorization` header across origins (redirect-hop SSRF). +pub fn init_admin_client() -> Result<(), String> { + if ADMIN_CLIENT.get().is_some() { + return Ok(()); + } + let client = reqwest::Client::builder() + // Pin bare `localhost` to loopback (exact-hostname override). + .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) + // Pin `.localhost` subdomain names (e.g. `admin.localhost`) to loopback. + // RFC 6761 §6.3 requires this but system getaddrinfo is unreliable on + // Linux/Windows CI runners; the custom resolver makes it deterministic + // across all supported platforms without changing non-localhost resolution. + .dns_resolver(LocalhostDnsResolver) + .pool_idle_timeout(std::time::Duration::from_secs(10)) + .pool_max_idle_per_host(2) + .redirect(reqwest::redirect::Policy::none()) + .timeout(ADMIN_TIMEOUT) + .build() + .map_err(|e| { + format!( + "admin HTTP client must build with redirect::Policy::none() \ + (redirect-hop SSRF): {e}" + ) + })?; + // Ignore the result: a concurrent initialiser may have won the race, which + // is fine — either way a no-redirect client is stored. + let _ = ADMIN_CLIENT.set(client); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The admin client must be buildable and must refuse to follow redirects. + /// This mirrors the `build_media_fetch_client_succeeds_with_no_redirect_policy` + /// test in `media_download.rs`. + #[test] + fn admin_client_builds_with_no_redirect_policy() { + init_admin_client().expect("client builds"); + assert!(ADMIN_CLIENT.get().is_some()); + } + + /// Repeated initialisation is a no-op and stays `Ok`. + #[test] + fn init_admin_client_is_idempotent() { + init_admin_client().expect("client builds"); + init_admin_client().expect("second call is a no-op"); + assert!(ADMIN_CLIENT.get().is_some()); + } + + /// A live test that the client does not follow a 302. + /// + /// Mirrors `media_fetch_client_does_not_follow_redirects` in + /// `media_download.rs`. Serves a 302 pointing at the metadata endpoint + /// and asserts exactly one connection was accepted. + #[tokio::test] + async fn admin_client_does_not_follow_redirects() { + use std::io::{Read, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + init_admin_client().expect("client builds"); + let client = ADMIN_CLIENT.get().expect("client initialised"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let connections = Arc::new(AtomicUsize::new(0)); + + let server_connections = Arc::clone(&connections); + let server = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + server_connections.fetch_add(1, Ordering::SeqCst); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = "HTTP/1.1 302 Found\r\n\ + Location: http://169.254.169.254/latest/meta-data/\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + let resp = client + .get(format!("http://{addr}/api/admin/v1/reports")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .expect("request should complete without following the redirect"); + + assert_eq!(resp.status().as_u16(), 302); + server.join().unwrap(); + assert_eq!( + connections.load(Ordering::SeqCst), + 1, + "exactly one request must be issued — redirect must not be followed", + ); + } +} diff --git a/desktop/src-tauri/src/commands/admin/discovery.rs b/desktop/src-tauri/src/commands/admin/discovery.rs new file mode 100644 index 0000000000..d02d2b2559 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/discovery.rs @@ -0,0 +1,579 @@ +//! NIP-11 admin-origin discovery with same-host trust binding. +//! +//! Fetches the relay's information document and extracts a validated admin +//! console origin from its `admin_api` field, together with a `same_host` flag +//! recording whether that advertised origin targets the same host as the +//! connected relay. +//! +//! # Trust model +//! +//! The `admin_api` value is untrusted relay input. On first mount with no saved +//! origin the desktop auto-saves and auto-probes a discovered origin, and +//! `admin_probe` signs a NIP-98 (kind-27235) header with the operator's key as +//! soon as the origin answers `401 WWW-Authenticate: Nostr`. Auto-probing a +//! *cross-host* advertisement would hand an attacker-controlled server an +//! unconsented signature proving the operator's key ownership and intent. +//! +//! The binding closes that gap: an origin whose host matches the connected +//! relay's host is trusted for auto-save + auto-probe (`same_host == true`); a +//! cross-host advertisement is still surfaced but marked `same_host == false`, +//! and the TypeScript layer treats it as pre-fill-only, requiring the operator +//! to review and explicitly save before anything is signed. Host identity is +//! the binding — scheme and port are not compared, so an operator can legitimately +//! run the admin console on a different port or scheme than the relay. +//! +//! Residual exposure is bounded even for a same-host relay the operator does not +//! fully trust: the NIP-98 header binds the exact request URL, method, and +//! payload, so a captured signature is neither replayable against another +//! endpoint nor usable as a general credential. +//! +//! Discovery still rejects private/reserved hosts and DNS-rebinding +//! (`advertised_host_is_reserved`, `advertised_hostname_resolves_private`) +//! regardless of the same-host flag. Separated from `mod.rs` to keep the parent +//! file under the repository's line-count gate. + +use super::origin; + +// ── NIP-11 admin-origin discovery ───────────────────────────────────────── + +/// Minimal projection of the relay's NIP-11 information document — only the +/// field needed to auto-discover the admin console origin. Unknown fields are +/// ignored, so a full NIP-11 document deserializes cleanly. +#[derive(serde::Deserialize)] +pub(super) struct AdminApiInfo { + #[serde(default)] + pub(super) admin_api: Option, +} + +/// Validate a relay-advertised `admin_api` value into a canonical origin. +/// +/// The value is untrusted relay input, so this is stricter than manual entry: +/// it is accepted only if it passes the same `AdminOrigin` structural +/// validation (origin only — no path, query, fragment, or credentials) AND its +/// host is not a reserved IP literal or the `localhost` name. Manual entry +/// still permits loopback `http` for local development; an auto-advertised +/// origin must never point the operator at an internal target. Hostname +/// targets are additionally DNS-checked by `discover_admin_origin_at` to reject +/// a public name that resolves to a private address (DNS-rebinding-safe). +/// +/// Passing this gate does not by itself authorise auto-probing: whether the +/// discovered origin is auto-saved and auto-probed or only pre-filled is +/// governed by the same-host binding (`advertised_host_matches_relay`), which +/// the caller records in the returned `same_host` flag. +/// +/// An absent, structurally invalid, or reserved-literal value yields `None` so +/// the desktop falls back to manual entry rather than offering an unsafe origin. +pub(super) fn admin_origin_from_nip11(info: &AdminApiInfo) -> Option { + let raw = info.admin_api.as_deref()?; + let origin = origin::AdminOrigin::parse(raw).ok()?; + if advertised_host_is_reserved(&origin) { + return None; + } + Some(origin) +} + +/// Whether an advertised origin's host is a reserved IP literal or `localhost`. +/// +/// IP literals are classified synchronously via the shared SSRF predicate; +/// the bare `localhost` name is rejected here because it never needs DNS to be +/// recognised as loopback. Every other hostname is resolved and re-checked in +/// `discover_admin_origin_at`. +pub(super) fn advertised_host_is_reserved(origin: &origin::AdminOrigin) -> bool { + match origin.resolution_target().0 { + url::Host::Ipv4(ip) => buzz_core_pkg::network::is_private_ip(&std::net::IpAddr::V4(ip)), + url::Host::Ipv6(ip) => buzz_core_pkg::network::is_private_ip(&std::net::IpAddr::V6(ip)), + url::Host::Domain(name) => name.eq_ignore_ascii_case("localhost"), + } +} + +/// Whether the advertised admin origin targets the same host as the relay +/// reachable at `relay_http_base`. +/// +/// Host identity is the trust binding for auto-save + auto-probe (see the module +/// docs); scheme and port are deliberately excluded so an operator can run the +/// admin console on a different port or scheme than the relay. The comparison is +/// ASCII-case-insensitive on the host string forms: `AdminOrigin` already +/// lowercases the advertised host, but the relay-URL side is compared defensively +/// rather than trusting the `url` crate to have lowercased it. IPv6 literals are +/// compared unbracketed on both sides. A relay base that fails to parse or has no +/// host yields `false`, so an unparseable relay URL never binds. +pub(super) fn advertised_host_matches_relay( + advertised: &origin::AdminOrigin, + relay_http_base: &str, +) -> bool { + let Ok(relay_url) = url::Url::parse(relay_http_base) else { + return false; + }; + let Some(relay_host) = relay_url.host_str() else { + return false; + }; + let relay_host = relay_host.trim_start_matches('[').trim_end_matches(']'); + advertised_host_string(&advertised.resolution_target().0).eq_ignore_ascii_case(relay_host) +} + +/// The bare host string of a parsed `url::Host`, without IPv6 brackets. +fn advertised_host_string(host: &url::Host) -> String { + match host { + url::Host::Domain(name) => name.clone(), + url::Host::Ipv4(ip) => ip.to_string(), + url::Host::Ipv6(ip) => ip.to_string(), + } +} + +/// Resolve an advertised hostname and reject if any address is private/reserved. +/// +/// Split out with an injectable resolver so the DNS-rebinding case (a public +/// name resolving to a private address) is unit-testable without live DNS. +pub(super) async fn advertised_hostname_resolves_private( + origin: &origin::AdminOrigin, + resolve: R, +) -> bool +where + R: Fn(String, u16) -> Fut, + Fut: std::future::Future, String>>, +{ + let (host, port) = origin.resolution_target(); + let url::Host::Domain(name) = host else { + // IP literals are already classified synchronously; nothing to resolve. + return false; + }; + match resolve(name, port).await { + Ok(addrs) => addrs.is_empty() || addrs.iter().any(buzz_core_pkg::network::is_private_ip), + // A resolution failure is not a positive private verdict; the reserved + // check has already run, and a same-host auto-probe or the operator's + // explicit save re-validates the origin against the live network. + Err(_) => false, + } +} + +/// Real DNS resolver used in production discovery. +pub(super) async fn resolve_host_addrs( + host: String, + port: u16, +) -> Result, String> { + let addrs = tokio::net::lookup_host((host.as_str(), port)) + .await + .map_err(|e| format!("admin origin DNS resolution failed: {e}"))? + .map(|addr| addr.ip()) + .collect(); + Ok(addrs) +} + +/// Deadline for the whole discovery request, body included. Discovery runs on +/// console mount, so a stalled relay must not hold it open. +const DISCOVERY_TIMEOUT: std::time::Duration = if cfg!(test) { + std::time::Duration::from_millis(500) +} else { + std::time::Duration::from_secs(10) +}; + +/// Body cap for `/info`, success or error. Real NIP-11 documents are a few KiB +/// (name, description, limitations, fees); 64 KiB leaves ample headroom while +/// refusing an unbounded buffer from a hostile relay. +const DISCOVERY_BODY_CAP: u64 = 65_536; + +/// Fetch the relay's NIP-11 document and extract a validated admin origin +/// together with its same-host binding flag. +/// +/// Returns `Ok(Some(DiscoveredAdminOrigin))` when the relay advertises a valid +/// `admin_api`, `Ok(None)` when the field is absent, fails validation, or +/// resolves to a private/reserved address, and `Err` on a transport or non-2xx +/// failure. The `same_host` flag records whether the advertised origin's host +/// matches `relay_http_base`'s host — the caller (TypeScript) uses it to gate +/// auto-save + auto-probe versus pre-fill-only. Split from the Tauri command so +/// it can be exercised against a live test server without constructing `AppState`. +pub(super) async fn discover_admin_origin_at( + client: &reqwest::Client, + relay_http_base: &str, +) -> Result, String> { + discover_admin_origin_at_with(client, relay_http_base, resolve_host_addrs).await +} + +/// `discover_admin_origin_at` with an injectable hostname resolver. +pub(super) async fn discover_admin_origin_at_with( + client: &reqwest::Client, + relay_http_base: &str, + resolve: R, +) -> Result, String> +where + R: Fn(String, u16) -> Fut, + Fut: std::future::Future, String>>, +{ + use crate::relay::classify_request_error; + + let url = format!("{}/info", relay_http_base.trim_end_matches('/')); + // The app-wide client has no request deadline, and `/info` is served by an + // untrusted relay: bound this request's total time (headers and body). + let response = client + .get(url) + .header("Accept", "application/nostr+json") + .timeout(DISCOVERY_TIMEOUT) + .send() + .await + .map_err(|error| classify_request_error(&error))?; + + let status = response.status(); + let body = super::read_bounded(response, DISCOVERY_BODY_CAP).await?; + if !status.is_success() { + let message = serde_json::from_slice::(&body) + .ok() + .and_then(|v| v.get("message")?.as_str().map(str::to_owned)); + return Err(match message { + Some(message) => format!("relay returned {status}: {message}"), + None => format!("relay returned {status}"), + }); + } + + let info: AdminApiInfo = serde_json::from_slice(&body) + .map_err(|e| format!("invalid NIP-11 document from relay: {e}"))?; + let Some(origin) = admin_origin_from_nip11(&info) else { + return Ok(None); + }; + if advertised_hostname_resolves_private(&origin, resolve).await { + return Ok(None); + } + let same_host = advertised_host_matches_relay(&origin, relay_http_base); + Ok(Some(super::DiscoveredAdminOrigin { + origin: origin.as_str().to_string(), + same_host, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + // ── Minimal TCP test server ──────────────────────────────────────────── + + type RequestInspector = Arc; + + async fn serve_sequence_inspect( + responses: Vec<(&'static str, &'static str, &'static str)>, + inspect: Option, + ) -> std::net::SocketAddr { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for (idx, (status, headers, body)) in responses.into_iter().enumerate() { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + if let Some(ref f) = inspect { + f(idx, &buf[..n]); + } + let body_bytes = body.as_bytes(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{headers}Connection: close\r\n\r\n", + body_bytes.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body_bytes); + let _ = stream.flush(); + } + } + }); + addr + } + + async fn serve_sequence( + responses: Vec<(&'static str, &'static str, &'static str)>, + ) -> std::net::SocketAddr { + serve_sequence_inspect(responses, None).await + } + + // ── NIP-11 admin-origin discovery ───────────────────────────────────── + + // Tests for admin_origin_from_nip11 — the untrusted-value gate. An advertised + // value only PRE-FILLS the operator's origin field (nothing probes it), so it + // is held to a stricter standard than manual entry: it must pass AdminOrigin's + // structural validation AND not target a reserved IP literal or `localhost`. + // DNS-resolving hostnames are re-checked in discover_admin_origin_at_with. + + #[test] + fn discover_parse_accepts_valid_public_https() { + let info = AdminApiInfo { + admin_api: Some("https://admin.example.com".to_string()), + }; + assert_eq!( + admin_origin_from_nip11(&info).map(|o| o.as_str().to_string()), + Some("https://admin.example.com".to_string()) + ); + } + + #[test] + fn discover_parse_none_when_absent() { + let info = AdminApiInfo { admin_api: None }; + assert!(admin_origin_from_nip11(&info).is_none()); + } + + #[test] + fn discover_parse_rejects_structurally_invalid_value() { + let info = AdminApiInfo { + admin_api: Some("http://admin.example.com".to_string()), + }; + assert!(admin_origin_from_nip11(&info).is_none()); + } + + #[test] + fn discover_parse_rejects_advertised_loopback_literal() { + for raw in [ + "http://127.0.0.1:3000", + "http://[::1]:3000", + "http://localhost:3000", + ] { + let info = AdminApiInfo { + admin_api: Some(raw.to_string()), + }; + assert!( + admin_origin_from_nip11(&info).is_none(), + "advertised loopback {raw:?} must be rejected" + ); + } + } + + #[test] + fn discover_parse_rejects_advertised_private_and_link_local_literal() { + for raw in [ + "https://10.0.0.5", + "https://192.168.1.1", + "https://172.16.0.1", + "https://169.254.169.254", + "https://[fe80::1]", + ] { + let info = AdminApiInfo { + admin_api: Some(raw.to_string()), + }; + assert!( + admin_origin_from_nip11(&info).is_none(), + "advertised private/link-local {raw:?} must be rejected" + ); + } + } + + #[tokio::test] + async fn discover_returns_origin_when_public_admin_api_advertised() { + let body = r#"{"name":"Buzz Relay","supported_nips":[1,11],"admin_api":"https://admin.example.com"}"#; + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/nostr+json\r\n", + body, + )]) + .await; + let client = reqwest::Client::new(); + let result = + discover_admin_origin_at_with(&client, &format!("http://{addr}"), |_host, _port| { + Box::pin(async { Ok(vec!["93.184.216.34".parse().unwrap()]) }) + }) + .await + .unwrap() + .expect("a valid public admin_api is discovered"); + assert_eq!(result.origin, "https://admin.example.com"); + // The relay under test is on 127.0.0.1; the advertised host differs, so + // the origin is surfaced but not same-host-bound for auto-probe. + assert!( + !result.same_host, + "cross-host advertisement must not be same-host-bound" + ); + } + + #[tokio::test] + async fn discover_returns_none_when_admin_api_absent() { + let body = r#"{"name":"Buzz Relay","supported_nips":[1,11]}"#; + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/nostr+json\r\n", + body, + )]) + .await; + let client = reqwest::Client::new(); + let result = discover_admin_origin_at(&client, &format!("http://{addr}")) + .await + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn discover_returns_none_when_advertised_value_invalid() { + let body = r#"{"admin_api":"http://admin.example.com"}"#; + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/nostr+json\r\n", + body, + )]) + .await; + let client = reqwest::Client::new(); + let result = discover_admin_origin_at(&client, &format!("http://{addr}")) + .await + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn discover_returns_none_when_public_hostname_resolves_private() { + let body = r#"{"admin_api":"https://admin.internal.example"}"#; + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/nostr+json\r\n", + body, + )]) + .await; + let client = reqwest::Client::new(); + let result = + discover_admin_origin_at_with(&client, &format!("http://{addr}"), |_host, _port| { + Box::pin(async { Ok(vec!["10.0.0.7".parse().unwrap()]) }) + }) + .await + .unwrap(); + assert!( + result.is_none(), + "a public name resolving to a private address must not be offered" + ); + } + + #[tokio::test] + async fn discover_errors_on_non_2xx() { + let addr = serve_sequence(vec![("500 Internal Server Error", "", "")]).await; + let client = reqwest::Client::new(); + let result = discover_admin_origin_at(&client, &format!("http://{addr}")).await; + assert!( + result.is_err(), + "non-2xx must surface as Err; got {result:?}" + ); + } + + #[tokio::test] + async fn discover_requests_info_path_with_nostr_accept_header() { + let captured: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); + let captured_bg = Arc::clone(&captured); + let body = r#"{"admin_api":"http://127.0.0.1:3000"}"#; + let addr = serve_sequence_inspect( + vec![("200 OK", "Content-Type: application/nostr+json\r\n", body)], + Some(Arc::new(move |_idx, bytes: &[u8]| { + captured_bg.lock().unwrap().extend_from_slice(bytes); + })), + ) + .await; + let client = reqwest::Client::new(); + let _ = discover_admin_origin_at(&client, &format!("http://{addr}")) + .await + .unwrap(); + let request = String::from_utf8_lossy(&captured.lock().unwrap()).to_string(); + assert!( + request.starts_with("GET /info "), + "discovery must GET /info; got: {:?}", + request.lines().next() + ); + assert!( + request + .to_ascii_lowercase() + .contains("accept: application/nostr+json"), + "discovery must send the NIP-11 Accept header" + ); + } + // ── Discovery bounds: size cap and deadline ──────────────────────────── + + /// Serve one connection: write `head`, then `body` chunks, then optionally + /// hold the socket open without finishing. + fn serve_raw(head: String, body: Vec>, stall: bool) -> std::net::SocketAddr { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let _ = stream.read(&mut buf); + let _ = stream.write_all(head.as_bytes()); + for chunk in body { + let _ = stream.write_all(&chunk); + } + let _ = stream.flush(); + if stall { + std::thread::sleep(std::time::Duration::from_secs(5)); + } + } + }); + addr + } + + fn chunk(data: &[u8]) -> Vec { + let mut out = format!("{:x}\r\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\r\n"); + out + } + + async fn discover( + addr: std::net::SocketAddr, + ) -> Result, String> { + discover_admin_origin_at(&reqwest::Client::new(), &format!("http://{addr}")).await + } + + /// The bounds must hold before the status branch, so success and error + /// bodies are both covered. + const BOUNDED_STATUSES: [&str; 2] = ["200 OK", "500 Internal Server Error"]; + + #[tokio::test] + async fn discover_rejects_an_oversized_body_with_a_known_length() { + for status in BOUNDED_STATUSES { + let body = vec![b' '; DISCOVERY_BODY_CAP as usize + 1]; + let head = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let err = discover(serve_raw(head, vec![body], false)) + .await + .unwrap_err(); + assert!(err.contains("too large"), "{status}: {err}"); + } + } + + #[tokio::test] + async fn discover_rejects_an_oversized_chunked_body() { + // No Content-Length: the cap must hold while streaming. + for status in BOUNDED_STATUSES { + let head = format!("HTTP/1.1 {status}\r\nContent-Type: application/nostr+json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"); + let piece = vec![b' '; 16_384]; + let mut body: Vec> = (0..5).map(|_| chunk(&piece)).collect(); + body.push(b"0\r\n\r\n".to_vec()); + let err = discover(serve_raw(head, body, false)).await.unwrap_err(); + assert!(err.contains("too large"), "{status}: {err}"); + } + } + + #[tokio::test] + async fn discover_gives_up_on_a_stalled_body() { + // Headers arrive, the body never finishes: the request deadline fires. + for status in BOUNDED_STATUSES { + let head = format!("HTTP/1.1 {status}\r\nContent-Type: application/nostr+json\r\nContent-Length: 100\r\n\r\n"); + let started = std::time::Instant::now(); + let result = discover(serve_raw(head, vec![b"{".to_vec()], true)).await; + assert!(result.is_err(), "{status}: {result:?}"); + assert!( + started.elapsed() < std::time::Duration::from_secs(3), + "{status}: discovery must not wait past its deadline: {:?}", + started.elapsed() + ); + } + } + + #[tokio::test] + async fn discover_reads_a_normal_nip11_document() { + let body = br#"{"name":"Buzz Relay","supported_nips":[1,11],"admin_api":"https://admin.example.com"}"#; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let addr = serve_raw(head, vec![body.to_vec()], false); + let found = discover_admin_origin_at_with( + &reqwest::Client::new(), + &format!("http://{addr}"), + |_host, _port| Box::pin(async { Ok(vec!["93.184.216.34".parse().unwrap()]) }), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(found.origin, "https://admin.example.com"); + } +} diff --git a/desktop/src-tauri/src/commands/admin/dns.rs b/desktop/src-tauri/src/commands/admin/dns.rs new file mode 100644 index 0000000000..01501fa226 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/dns.rs @@ -0,0 +1,153 @@ +//! Custom DNS resolver for the admin HTTP client. +//! +//! [`LocalhostDnsResolver`] pins RFC 6761 `.localhost` names (e.g. +//! `admin.localhost`) to the loopback address (`127.0.0.1`) and delegates +//! all other names to the system getaddrinfo resolver via a blocking thread. +//! +//! # Why a custom resolver +//! +//! The `http://admin.localhost:` origin is the canonical form used by +//! the relay's `just admin` target. RFC 6761 §6.3 requires `.localhost` +//! subdomains to resolve to loopback, but system getaddrinfo does not honour +//! this on all supported platforms: +//! +//! - **macOS**: resolves correctly via mDNSResponder. +//! - **Linux/glibc**: relies on `nsswitch.conf` ordering; GitHub Actions +//! ubuntu-latest runners do NOT resolve `.localhost` subdomains. +//! - **Windows**: not guaranteed by the system resolver. +//! +//! The resolver intercepts only names ending in `.localhost` and returns +//! `127.0.0.1:0`; all other names fall through to the system resolver, +//! so non-localhost resolution is unchanged. + +use std::net::SocketAddr; + +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; + +/// DNS resolver used by the admin HTTP client. +/// +/// - Names ending in `.localhost` are pinned to `127.0.0.1:0` (RFC 6761 §6.3). +/// - All other names are forwarded to the system `getaddrinfo` resolver. +#[derive(Debug, Clone)] +pub struct LocalhostDnsResolver; + +impl Resolve for LocalhostDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + // RFC 6761 §6.3: `.localhost` subdomains must resolve to loopback. + // The admin client's exact `resolve("localhost", …)` already covers the + // bare hostname; this resolver handles the subdomain case. + if name.as_str().ends_with(".localhost") { + let addrs: Addrs = Box::new(std::iter::once(SocketAddr::from(([127, 0, 0, 1], 0)))); + return Box::pin(async move { Ok(addrs) }); + } + + // Fall through to the system resolver for all other names. + let host = name.as_str().to_owned(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + use std::net::ToSocketAddrs; + let addrs = (host.as_str(), 0u16) + .to_socket_addrs() + .map_err(|e| Box::new(e) as Box)?; + Ok::>(Box::new(addrs)) + }) + .await + .map_err(|e| Box::new(e) as Box)? + }) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + use super::*; + use reqwest::dns::Resolve; + + /// Parse a [`reqwest::dns::Name`] from a &str. + fn make_name(s: &str) -> Name { + Name::from_str(s).unwrap_or_else(|_| panic!("invalid DNS name: {s}")) + } + + // ── .localhost pinning ──────────────────────────────────────────────── + + /// A `.localhost` subdomain must resolve to exactly `127.0.0.1:0`. + /// + /// "Exactly" matters: macOS system GAI returns `127.0.0.1` (port chosen by + /// the caller), but the resolver must produce the singleton `127.0.0.1:0` + /// via the fast pinning branch — not via system GAI. The mutation test + /// below confirms the branch itself is load-bearing: removing it causes + /// this test to hang or yield a different address on Linux/Windows CI + /// where system GAI does not resolve `.localhost` subdomains. + #[tokio::test] + async fn dot_localhost_resolves_to_exactly_loopback_zero_port() { + let resolver = LocalhostDnsResolver; + let mut addrs: Vec = resolver + .resolve(make_name("admin.localhost")) + .await + .expect(".localhost must resolve without error") + .collect(); + assert_eq!( + addrs.len(), + 1, + "resolver must return exactly one address for admin.localhost; got {addrs:?}" + ); + let addr = addrs.remove(0); + assert_eq!( + addr, + SocketAddr::from(([127, 0, 0, 1], 0)), + "admin.localhost must pin to exactly 127.0.0.1:0, not {addr}" + ); + } + + /// Multi-level `.localhost` subdomain is also pinned. + #[tokio::test] + async fn multi_level_dot_localhost_resolves_to_loopback() { + let resolver = LocalhostDnsResolver; + let addrs: Vec = resolver + .resolve(make_name("deep.sub.localhost")) + .await + .expect("multi-level .localhost must resolve") + .collect(); + assert!( + addrs.iter().all(|a| a.ip().is_loopback()), + "all resolved addresses must be loopback for deep.sub.localhost; got {addrs:?}" + ); + assert!( + addrs.contains(&SocketAddr::from(([127, 0, 0, 1], 0))), + "127.0.0.1:0 must be present for deep.sub.localhost; got {addrs:?}" + ); + } + + // ── delegation for non-.localhost names ─────────────────────────────── + + /// A plain `localhost` (no dot-prefix) falls through to system GAI and + /// resolves to a loopback address. This proves the resolver does NOT + /// intercept non-`.localhost` names — it delegates faithfully. + /// + /// `localhost` is chosen because it is reliably resolvable by system GAI + /// on every supported platform (macOS/Linux/Windows) without any network + /// traffic, so this test is deterministic and offline-safe. + #[tokio::test] + async fn non_dot_localhost_delegates_to_system_gai() { + let resolver = LocalhostDnsResolver; + // `localhost` does NOT end with `.localhost` so it must fall through. + let addrs: Vec = resolver + .resolve(make_name("localhost")) + .await + .expect("bare localhost must resolve via system GAI") + .collect(); + assert!( + !addrs.is_empty(), + "system GAI must return at least one address for localhost" + ); + assert!( + addrs.iter().all(|a| a.ip().is_loopback()), + "system GAI localhost addresses must all be loopback; got {addrs:?}" + ); + // Crucially: none of these are produced by the early-return branch + // (which only fires for `.localhost`). We confirm that by verifying the + // results came from GAI: GAI typically returns port 0 as well, but may + // return ::1 in addition to 127.0.0.1 — either is acceptable. + } +} diff --git a/desktop/src-tauri/src/commands/admin/error.rs b/desktop/src-tauri/src/commands/admin/error.rs new file mode 100644 index 0000000000..75e3f3a370 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/error.rs @@ -0,0 +1,69 @@ +//! Typed error for admin mutation commands. + +/// Error from an admin mutation command, carrying whether the relay +/// authoritatively answered so the UI can decide idempotency-retry policy +/// without string-matching the message. +/// +/// `relayStatus` is `Some(code)` only when the relay returned an HTTP status — +/// the request reached the relay and it answered. It is `None` for a +/// pre-response transport failure (`send()` error, DNS/connect/timeout) or a +/// pre-send failure (auth build, body serialisation): the relay never +/// committed anything, so a retry must reuse the same idempotency key. +/// +/// `bodyComplete` is `true` only when the relay's full response body was read — +/// an authoritative verdict. A non-409 4xx with `bodyComplete: true` is a +/// definitive pre-commit rejection, so the UI may mint a fresh idempotency key. +/// A status that arrives but whose body is lost mid-stream (or rejected over +/// the size cap) carries `bodyComplete: false`: the outcome is unknown, so the +/// caller preserves idempotency and lets the retry dedupe even on a 4xx. +/// +/// Serialises `rename_all = "camelCase"`; the JS bridge surfaces it as the +/// rejected `TauriInvokeError.payload`, from which the UI reads `relayStatus` +/// and `bodyComplete`. +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminMutationError { + /// Human-readable message — byte-identical to the string the command + /// produced before typing, so existing message parsing is unaffected. + pub message: String, + /// The relay's HTTP status when a response was received; `None` for a + /// transport/pre-send failure where no relay answer exists. + pub relay_status: Option, + /// Whether the relay's full response body was read. `true` only for an + /// authoritative verdict; `false` when the body was lost or truncated. + pub body_complete: bool, +} + +impl AdminMutationError { + /// The relay answered with an HTTP status and its full body was read — an + /// authoritative verdict. + pub(super) fn authoritative(status: reqwest::StatusCode, message: String) -> Self { + Self { + message, + relay_status: Some(status.as_u16()), + body_complete: true, + } + } + + /// The relay answered with an HTTP status but the body was not fully read + /// (redirect, over-cap, or a mid-stream read failure) — outcome unknown. + pub(super) fn partial(status: reqwest::StatusCode, message: String) -> Self { + Self { + message, + relay_status: Some(status.as_u16()), + body_complete: false, + } + } +} + +/// Pre-send and transport failures carry no relay status: the relay never saw +/// the request (or never answered), so the outcome is unambiguously "no commit". +impl From for AdminMutationError { + fn from(message: String) -> Self { + Self { + message, + relay_status: None, + body_complete: false, + } + } +} diff --git a/desktop/src-tauri/src/commands/admin/helpers.rs b/desktop/src-tauri/src/commands/admin/helpers.rs new file mode 100644 index 0000000000..079dcaba17 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/helpers.rs @@ -0,0 +1,364 @@ +//! HTTP helpers for the desktop admin surface. +//! +//! NIP-98 authenticated fetch/mutation wrappers and response-reading utilities +//! used by the Tauri command implementations in `mod.rs`. + +use super::client; +use super::{AdminMutationError, ATTACHMENT_CAP, ERROR_BODY_CAP}; + +/// Fetch a JSON endpoint with NIP-98 auth, one 401-retry, and a size cap. +pub(super) async fn fetch_admin_json( + url: &str, + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + use crate::relay::build_nip98_auth_header_for_keys; + + let keys = state.signing_keys()?; + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + let auth_header = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed: {e}"))?; + + let resp = http_client + .get(url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + // One retry on 401 with a fresh NIP-98 event (new nonce). + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let auth_header2 = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed on retry: {e}"))?; + let resp2 = http_client + .get(url) + .header(reqwest::header::AUTHORIZATION, auth_header2) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + return read_admin_response(resp2, cap, ERROR_BODY_CAP).await; + } + + read_admin_response(resp, cap, ERROR_BODY_CAP).await +} + +/// POST a JSON body with NIP-98 auth (payload sha256 in the tag), one 401-retry, size cap. +pub(super) async fn post_admin_json( + url: &str, + body: &[u8], + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + mutation_admin_json(reqwest::Method::POST, url, Some(body), cap, state).await +} + +/// PATCH a JSON body with NIP-98 auth (payload sha256), one 401-retry, size cap. +pub(super) async fn patch_admin_json( + url: &str, + body: &[u8], + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + mutation_admin_json(reqwest::Method::PATCH, url, Some(body), cap, state).await +} + +/// PUT a JSON body with NIP-98 auth (payload sha256), one 401-retry, size cap. +pub(super) async fn put_admin_json( + url: &str, + body: &[u8], + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + mutation_admin_json(reqwest::Method::PUT, url, Some(body), cap, state).await +} + +/// DELETE with NIP-98 auth (no body), one 401-retry, size cap. +/// +/// A thin wrapper over [`mutation_admin_json`] with `body: None`, so a config- +/// backed 409 arrives as an authoritative [`AdminMutationError`] the UI can act +/// on — not an opaque `String`. +pub(super) async fn delete_admin_json( + url: &str, + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + mutation_admin_json(reqwest::Method::DELETE, url, None, cap, state).await +} + +/// Shared implementation for POST/PATCH/PUT and bodyless DELETE. +/// +/// `body` is `Some(bytes)` for a JSON-bearing request (NIP-98 §4 `payload` tag +/// over the SHA-256 of the exact bytes, `Content-Type: application/json`, those +/// bytes on the wire) and `None` for a bodyless request (signed over `&[]` with +/// no `payload` tag, and NO wire body or `Content-Type` header — byte-identical +/// to a bare DELETE the relay expects). +/// +/// Returns a typed [`AdminMutationError`] so the caller can distinguish a +/// relay-authoritative failure (a status was received) from a transport or +/// pre-send failure (no relay answer). `?` on the `String`-producing steps +/// (auth build, `send()` classification) converts via `From` to a +/// no-status error, which is correct: none of those reached a relay verdict. +pub(super) async fn mutation_admin_json( + method: reqwest::Method, + url: &str, + body: Option<&[u8]>, + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + let keys = state.signing_keys()?; + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + let resp = build_admin_mutation_request(http_client, &keys, &method, url, body)? + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + // Retry once with a freshly signed request — each build mints a new + // NIP-98 nonce, so this is a distinct event, not a replay of the first. + let resp2 = build_admin_mutation_request(http_client, &keys, &method, url, body)? + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + return read_admin_mutation_response(resp2, cap, ERROR_BODY_CAP).await; + } + + read_admin_mutation_response(resp, cap, ERROR_BODY_CAP).await +} + +/// Build a NIP-98-authorized admin mutation request for `method`/`url`. +/// +/// `body` is `Some(bytes)` for a JSON-bearing request (POST/PUT/PATCH): a +/// `payload` tag over sha256(bytes), a `Content-Type: application/json` header, +/// and those bytes on the wire. `body` is `None` for a bodyless request +/// (DELETE): signed over `&[]` — the same empty-payload NIP-98 event the bare +/// DELETE carried — with NO `Content-Type` or wire body, so it is byte-identical +/// on the wire. Each call mints a fresh nonce (see +/// [`crate::relay::build_nip98_auth_header_for_keys`]), which is why the +/// 401-retry re-invokes this rather than resending the first request. +/// +/// State-free (`&Client` + `&Keys`) so the wire shape is unit-testable without +/// a running Tauri app. +pub(super) fn build_admin_mutation_request( + http_client: &reqwest::Client, + keys: &nostr::Keys, + method: &reqwest::Method, + url: &str, + body: Option<&[u8]>, +) -> Result { + let auth_header = + crate::relay::build_nip98_auth_header_for_keys(keys, method, url, body.unwrap_or(&[])) + .map_err(|e| format!("nip98 build failed: {e}"))?; + + let mut req = http_client + .request(method.clone(), url) + .header(reqwest::header::AUTHORIZATION, auth_header); + + // Only a body-bearing request sets a wire body and Content-Type; a bodyless + // request sends neither, matching the bare DELETE contract. + if let Some(bytes) = body { + req = req + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(bytes.to_vec()); + } + + Ok(req) +} + +/// What the fetched attachment bytes are for; decides the Content-Type rule. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum AttachmentUse { + /// Rendered in the webview: the relay's type must equal the imeta MIME. + Preview, + /// Written only to a user-chosen file, never rendered. The relay serves + /// non-raster files as `application/octet-stream` + `attachment` by + /// design, so that is accepted alongside the imeta MIME. + Save, +} + +/// Stream and validate an attachment response, enforcing Content-Type, size, +/// and the cap. +pub(super) async fn finish_attachment_response( + resp: reqwest::Response, + expected_mime: &str, + expected_size: u64, + purpose: AttachmentUse, +) -> Result, String> { + use futures_util::StreamExt; + + if resp.status().is_redirection() { + return Err("admin_attachment_redirect".to_string()); + } + if !resp.status().is_success() { + return Err(format!( + "admin_attachment_relay_error_{}", + resp.status().as_u16() + )); + } + + // Verify Content-Type before reading the body. + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + let type_ok = content_type == expected_mime.trim().to_ascii_lowercase() + || (purpose == AttachmentUse::Save && content_type == "application/octet-stream"); + if !type_ok { + return Err("admin_attachment_mime_mismatch".to_string()); + } + + // Content-Length preflight. + if let Some(cl) = resp.content_length() { + if cl > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + if cl != expected_size { + return Err("admin_attachment_size_mismatch".to_string()); + } + } + + // Stream with running byte counter. + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "admin_attachment_stream_error".to_string())?; + if bytes.len() as u64 + chunk.len() as u64 > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + bytes.extend_from_slice(&chunk); + } + + // Final size check. + if bytes.len() as u64 != expected_size { + return Err("admin_attachment_size_mismatch".to_string()); + } + + Ok(bytes) +} + +/// Read a response body up to `success_cap` bytes on 2xx, `error_cap` on +/// non-2xx. Redirects are treated as errors (the no-redirect client surfaced +/// them rather than following). +pub(super) async fn read_admin_response( + resp: reqwest::Response, + success_cap: u64, + error_cap: u64, +) -> Result, String> { + use futures_util::StreamExt; + + if resp.status().is_redirection() { + return Err(format!( + "admin API returned a {} redirect (not followed)", + resp.status() + )); + } + + let (is_success, cap) = if resp.status().is_success() { + (true, success_cap) + } else { + (false, error_cap) + }; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(format!( + "admin response too large ({cl} bytes, cap {cap} bytes)" + )); + } + } + + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("admin response stream error: {e}"))?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("admin response too large (cap {cap} bytes)")); + } + bytes.extend_from_slice(&chunk); + } + + if !is_success { + let body = String::from_utf8_lossy(&bytes); + return Err(format!("admin API error: {body}")); + } + + Ok(bytes) +} + +/// Read a mutation response, preserving the relay's HTTP status in the error. +/// +/// Mirrors [`read_admin_response`]'s size discipline and message wording so the +/// UI's message parsing is unchanged, but on a non-2xx it returns an +/// [`AdminMutationError`] tagged with the received status and whether the full +/// body was read. Only a status with a complete body (`authoritative`) is a +/// verdict the UI treats as definitive; a redirect, an over-cap body, or a +/// mid-stream read failure carries the status as `partial` — the relay answered +/// but the outcome is unknown, so the caller preserves the idempotency key and +/// lets the retry dedupe against any commit that landed. +async fn read_admin_mutation_response( + resp: reqwest::Response, + success_cap: u64, + error_cap: u64, +) -> Result, AdminMutationError> { + use futures_util::StreamExt; + + let status = resp.status(); + + if status.is_redirection() { + return Err(AdminMutationError::partial( + status, + format!("admin API returned a {status} redirect (not followed)"), + )); + } + + let (is_success, cap) = if status.is_success() { + (true, success_cap) + } else { + (false, error_cap) + }; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(AdminMutationError::partial( + status, + format!("admin response too large ({cl} bytes, cap {cap} bytes)"), + )); + } + } + + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| { + AdminMutationError::partial(status, format!("admin response stream error: {e}")) + })?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(AdminMutationError::partial( + status, + format!("admin response too large (cap {cap} bytes)"), + )); + } + bytes.extend_from_slice(&chunk); + } + + if !is_success { + let body = String::from_utf8_lossy(&bytes); + return Err(AdminMutationError::authoritative( + status, + format!("admin API error: {body}"), + )); + } + + Ok(bytes) +} diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs new file mode 100644 index 0000000000..2f7b591d4b --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -0,0 +1,1144 @@ +//! Desktop in-app admin surface — NIP-98 client for `/api/admin/v1`. +//! +//! Implements five Tauri commands that fetch JSON and binary content from the +//! relay's deployment-admin API using the app keypair as the NIP-98 signing +//! identity. A sixth command, `admin_probe`, discovers which authentication +//! mode the configured admin origin is running and whether the app identity +//! is authorized. +//! +//! # Security model +//! +//! The webview never supplies paths, methods, or full URLs. Every IPC command +//! accepts an `AdminOrigin` (scheme + host + optional port, validated on +//! construction) and typed query parameters; the final URL is built natively +//! from a closed route enum. The URL that is signed is byte-identical to the +//! URL that is fetched. +//! +//! A dedicated no-redirect reqwest client prevents redirect-hop SSRF — a relay +//! 3xx is returned verbatim and treated as an error so the NIP-98 header is +//! never forwarded across origins. +//! +//! Keys are acquired via `AppState::signing_keys()`, which returns `Err` when +//! the identity is in recovery mode (keyring locked or lost), ensuring the app +//! keypair can never sign admin events under an inaccessible identity. +//! +//! Response sizes are bounded by Content-Length preflight and a streaming byte +//! counter, mirroring the `media_download.rs` pattern. + +pub mod client; +pub(crate) mod dns; +pub(super) mod helpers; +pub(crate) mod origin; +pub(crate) mod routes; + +// ── Response size caps ──────────────────────────────────────────────────── + +/// Success-JSON cap: reports list returns up to 200 rows, each note field +/// can reach the 256 KiB event-content cap. Sized for the worst case. +const SUCCESS_JSON_CAP: u64 = 52_428_800; // 50 MiB + +/// Probe-response cap: `/probe` returns a tiny fixed-shape JSON envelope. +/// 8 KiB is far more than the payload needs while bounding a hostile body. +const PROBE_JSON_CAP: u64 = 8_192; // 8 KiB + +/// Error-body cap: relay error responses are brief JSON envelopes. +const ERROR_BODY_CAP: u64 = 65_536; // 64 KiB + +/// Attachment preview cap. 10 MiB is generous for images and small documents +/// while protecting against accidental OOM. +const ATTACHMENT_CAP: u64 = 10_485_760; // 10 MiB + +// Re-export helpers into this module's namespace. +use helpers::{ + delete_admin_json, fetch_admin_json, patch_admin_json, post_admin_json, put_admin_json, +}; + +// ── Typed mutation error ────────────────────────────────────────────────── + +pub(crate) mod error; + +mod attachment; +pub use error::AdminMutationError; + +// ── Typed probe result ──────────────────────────────────────────────────── + +/// Result of an `admin_probe` call. Each variant maps to a distinct UI state. +/// Tauri serialises this as `{ "state": "", ... }`. +#[derive(Debug, serde::Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum AdminProbeResult { + /// NIP-98 mode is active and the current app keypair is on the allowlist. + /// Includes the principal's `role` and `source` for the staffing tab. + Nip98Authorized { + /// The resolved role: `"operator"` or `"moderator"`. + role: Option, + /// How the role was resolved: `"config"`, `"owner_fallback"`, or `"db"`. + source: Option, + }, + /// NIP-98 mode is active but the app keypair was rejected after a signed + /// attempt. Likely: pubkey not in `RELAY_OPERATOR_PUBKEYS`, clock skew, or + /// relay config mismatch. + Nip98Denied, + /// Auth is disabled (`BUZZ_ADMIN_AUTH=disabled`). No credential needed. + Disabled, + /// The origin is reachable but the `/api/admin/v1` prefix is absent or + /// returns a non-admin response. + NotAdminApi, + /// Network/TLS error, DNS failure, or Cloudflare Access interception. + NetworkOrIntercepted, +} + +// ── Typed query struct ──────────────────────────────────────────────────── + +/// Query parameters accepted by `admin_list_reports`. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportsQuery { + pub community_id: Option, + pub status: Option, + pub report_type: Option, + pub target_kind: Option, + pub after: Option, + pub before: Option, + pub limit: Option, + /// Visibility scope forwarded to the relay's `scope` query parameter. + /// `Some("all")` requests every status; `None` uses the relay's default + /// (escalated-only). Only `"all"` is a valid value — the TypeScript layer + /// constrains the type to `"all" | undefined`. + pub scope: Option, +} + +// ── Probe ───────────────────────────────────────────────────────────────── + +/// A boxed signing closure: given a URL, returns a `Nostr ` Authorization header. +type SignFn = Box Result + Send + Sync>; + +/// Probe an admin origin to determine the authentication mode and whether the +/// current app keypair is authorized. +/// +/// Algorithm: +/// 1. Send an unauthenticated GET to `/api/admin/v1/probe`. +/// 2. Detect HTML/interception pages (Cloudflare Access, captive portals) +/// from Content-Type and final URL host → `NetworkOrIntercepted`. +/// 3. 200 + valid `ProbeResponse` with `authMode: "disabled"` → `Disabled`. +/// 4. 401 + `WWW-Authenticate: Nostr` → NIP-98 mode. Retry with a freshly +/// signed kind-27235. 200 + valid `ProbeResponse` → `Nip98Authorized` +/// carrying the relay-resolved `role`/`source`; non-200 → `Nip98Denied`. +/// 5. Any other 401 (including a `WWW-Authenticate: Bearer` challenge, which +/// is no longer a recognized Buzz admin mode) → `NotAdminApi`. +/// 6. 403/404 or other non-401 → `NotAdminApi`. +/// 7. Network/redirect/TLS error → `NetworkOrIntercepted`. +#[tauri::command] +pub async fn admin_probe( + origin: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + use crate::relay::build_nip98_auth_header_for_keys; + + // Resolve signing keys before entering the inner probe. Recovery mode + // (locked/lost keyring) is surfaced here rather than inside the loop. + let sign: Option = match state.signing_keys() { + Ok(keys) => Some(Box::new(move |url: &str| { + build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed: {e}")) + })), + Err(_) => None, + }; + + admin_probe_inner(&origin, sign).await +} + +/// Inner probe implementation with injectable signing. +/// +/// Accepts an optional signing closure so live-listener tests can drive the +/// full state machine — including the Nostr challenge/response path — without +/// requiring a real `AppState`. `None` simulates recovery mode (no key). +async fn admin_probe_inner( + origin: &str, + sign: Option Result>, +) -> Result { + let origin = origin::AdminOrigin::parse(origin)?; + let url = origin.route_url(&routes::AdminRoute::Probe, &routes::AdminQuery::default()); + + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + // Step 1: unauthenticated GET. + let resp = match http_client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + tracing::debug!(error = %e, "admin_probe: network error"); + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + }; + + if resp.status().is_redirection() { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Step 2: detect HTML/interception before reading body or interpreting status. + if is_probe_response_intercepted(&resp) { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Step 3: success without auth → disabled mode (only when the body is a + // coherent disabled-mode probe: status ok, authMode disabled, no + // principal, no capabilities). A 200 in any other shape or mode is a + // contract violation (token/nip98 must 401 an unauthenticated caller); + // classify defensively. + if resp.status().is_success() { + let content_type = response_content_type(&resp); + let bytes = read_bounded(resp, PROBE_JSON_CAP).await?; + return Ok(match parse_probe(&content_type, &bytes) { + Some(p) if p.is_coherent_disabled() => AdminProbeResult::Disabled, + _ => AdminProbeResult::NotAdminApi, + }); + } + + // Step 4–6: interpret 401. + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let www_auth = resp + .headers() + .get(reqwest::header::WWW_AUTHENTICATE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + + if www_auth.starts_with("nostr") { + // NIP-98 mode: try signing. + let auth_header = match &sign { + Some(f) => f(&url)?, + None => return Ok(AdminProbeResult::Nip98Denied), + }; + let auth_resp = match http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + { + Ok(r) => r, + Err(_) => return Ok(AdminProbeResult::NetworkOrIntercepted), + }; + + // Redirects on the authenticated retry are also interception. + if auth_resp.status().is_redirection() { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Validate the Authorization header was accepted by checking for HTML. + if is_probe_response_intercepted(&auth_resp) { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + if auth_resp.status().is_success() { + let content_type = response_content_type(&auth_resp); + let bytes = read_bounded(auth_resp, PROBE_JSON_CAP).await?; + return Ok(match parse_probe(&content_type, &bytes) { + // Trust the 2xx only when the full NIP-98 invariant holds; + // carry the relay-resolved role/source for the staffing tab. + Some(p) => match p.authorized_principal() { + Some((role, source)) => AdminProbeResult::Nip98Authorized { + role: Some(role.as_str().to_string()), + source: Some(source.as_str().to_string()), + }, + // Structurally a probe body but not a coherent + // authorized NIP-98 response: fail closed. + None => AdminProbeResult::NotAdminApi, + }, + // 2xx but not a probe shape: endpoint exists but isn't + // the admin API. + None => AdminProbeResult::NotAdminApi, + }); + } + return Ok(AdminProbeResult::Nip98Denied); + } + + // Any other 401 shape (including a Bearer challenge, which is no longer + // a recognized Buzz admin mode) is an unrecognized auth challenge. + return Ok(AdminProbeResult::NotAdminApi); + } + + Ok(AdminProbeResult::NotAdminApi) +} + +/// Check the response Content-Type and final URL host for signs of +/// captive-portal or Cloudflare Access interception. +/// +/// Uses the same classification logic as `relay.rs::classify_intercepted_response`. +fn is_probe_response_intercepted(resp: &reqwest::Response) -> bool { + let host = resp.url().host_str().unwrap_or("").to_lowercase(); + let ct = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_lowercase(); + + // Cloudflare Access redirects to its own domain. + if host == "cloudflareaccess.com" || host.ends_with(".cloudflareaccess.com") { + return true; + } + // Any HTML body from a non-relay host is a proxy/captive portal page. + if ct.contains("text/html") { + return true; + } + false +} + +/// Read a bounded response body (no auth check, just bytes). +async fn read_bounded(resp: reqwest::Response, cap: u64) -> Result, String> { + use futures_util::StreamExt; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(format!("probe response too large ({cl} bytes)")); + } + } + let mut bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("probe stream error: {e}"))?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("probe response too large (cap {cap} bytes)")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +/// The relay's `/probe` response contract (`ProbeResponse` in the relay's +/// `api/admin/mod.rs`, serialised `rename_all = "camelCase"`). +/// +/// All six fields are required and typed; deserialisation rejects a body that +/// omits any field or carries a wrong-typed one, so an unrelated JSON endpoint +/// cannot be mistaken for the admin API. Unknown fields are tolerated for +/// forward compatibility. Structural validity alone does NOT authorize: a +/// deserialised `ProbeWire` still has to pass [`ProbeWire::authorized_principal`] +/// or [`ProbeWire::is_coherent_disabled`] before its state is trusted. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProbeWire { + status: String, + auth_mode: String, + role: Option, + source: Option, + can_act: bool, + can_staff: bool, +} + +/// The relay's resolved principal role. Closed vocabulary — an unknown string +/// fails to parse and denies authorization. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProbeRole { + Operator, + Moderator, +} + +impl ProbeRole { + fn parse(s: &str) -> Option { + match s { + "operator" => Some(Self::Operator), + "moderator" => Some(Self::Moderator), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Operator => "operator", + Self::Moderator => "moderator", + } + } +} + +/// How the relay established the principal's role. Closed vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProbeSource { + Config, + OwnerFallback, + Db, +} + +impl ProbeSource { + fn parse(s: &str) -> Option { + match s { + "config" => Some(Self::Config), + "owner_fallback" => Some(Self::OwnerFallback), + "db" => Some(Self::Db), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Config => "config", + Self::OwnerFallback => "owner_fallback", + Self::Db => "db", + } + } +} + +impl ProbeWire { + /// Validate the complete NIP-98 authorization invariant and return the + /// typed principal only when every field is coherent with the relay + /// contract: `status == "ok"`, `authMode == "nip98"`, a recognised + /// non-null `role`/`source`, `canAct == true`, and + /// `canStaff == (role == operator)`. Any deviation yields `None`, so the + /// caller classifies the response `NotAdminApi` rather than trusting a + /// fail-open 2xx. + fn authorized_principal(&self) -> Option<(ProbeRole, ProbeSource)> { + if self.status != "ok" || self.auth_mode != "nip98" { + return None; + } + let role = ProbeRole::parse(self.role.as_deref()?)?; + let source = ProbeSource::parse(self.source.as_deref()?)?; + if !self.can_act || self.can_staff != (role == ProbeRole::Operator) { + return None; + } + Some((role, source)) + } + + /// Validate the disabled-mode invariant for an unauthenticated 200: + /// `status == "ok"`, `authMode == "disabled"`, no principal, no + /// capabilities. Any deviation is a contract violation (token/nip98 must + /// 401 an unauthenticated caller). + fn is_coherent_disabled(&self) -> bool { + self.status == "ok" + && self.auth_mode == "disabled" + && self.role.is_none() + && self.source.is_none() + && !self.can_act + && !self.can_staff + } +} + +/// Parse a `/probe` response body into a [`ProbeWire`], returning `None` when +/// the Content-Type is not JSON or the body does not match the probe contract. +/// +/// Strict typing rejects unrelated JSON endpoints: a response missing any +/// required field (`status`, `authMode`, `canAct`, `canStaff`) or carrying a +/// wrong-typed field fails to deserialise and yields `None`, so a non-admin +/// origin that happens to return JSON is classified `NotAdminApi` rather than +/// mistaken for the admin API. +fn parse_probe(content_type: &str, bytes: &[u8]) -> Option { + if !content_type + .to_ascii_lowercase() + .starts_with("application/json") + { + return None; + } + serde_json::from_slice::(bytes).ok() +} + +/// Extract the normalised Content-Type base value (strips parameters). +fn response_content_type(resp: &reqwest::Response) -> String { + resp.headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase() +} + +// ── Five typed data commands ────────────────────────────────────────────── + +/// Fetch the reports list. +#[tauri::command] +pub async fn admin_list_reports( + origin: String, + query: AdminReportsQuery, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let q = routes::AdminQuery { + community_id: query.community_id, + status: query.status, + report_type: query.report_type, + target_kind: query.target_kind, + after: query.after, + before: query.before, + limit: query.limit, + scope: query.scope, + cursor: None, + community_host: None, + }; + let url = origin.route_url(&routes::AdminRoute::ReportsList, &q); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch a single report's detail. +#[tauri::command] +pub async fn admin_get_report( + origin: String, + id: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportDetail { id }, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch the feedback list. +#[tauri::command] +pub async fn admin_list_feedback( + origin: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackList, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch a single feedback entry's detail (including imeta attachment metadata). +#[tauri::command] +pub async fn admin_get_feedback( + origin: String, + id: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "feedback id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackDetail { id }, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Resolve a report — POST /api/admin/v1/reports/{id}/resolve. +/// +/// Body: `{action, request_id, expiration_secs?, reason?}`. +/// The `request_id` is a client-generated UUID for idempotency; the caller +/// must generate once per resolution attempt and reuse on retry. +#[tauri::command] +pub async fn admin_resolve_report( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportResolve { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Reopen a resolved report — POST /api/admin/v1/reports/{id}/reopen. +/// +/// Body: `{request_id, reason?}`. The `request_id` is a client-generated UUID +/// for idempotency; the caller must generate once per reopen attempt and reuse +/// on retry. Reopen is re-triage only — it moves a `resolved`/`dismissed`/ +/// `escalated` report back to `open` and does not reverse any enforcement +/// action (bans, deletions) taken while it was resolved. +#[tauri::command] +pub async fn admin_reopen_report( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportReopen { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Cancel a failed enforcement action — POST /api/admin/v1/reports/{id}/cancel. +/// +/// Body: `{actionId}`. Cancel is the only recovery path for a pre-mutation +/// `failed` action: it returns the report to `open` for a fresh resolution. +/// The `actionId` fences the cancel to the failed action the operator observed; +/// a mismatch (already cancelled, superseded, or past the mutation point) is a +/// 409, which the caller treats as "refresh detail". +#[tauri::command] +pub async fn admin_cancel_report( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportCancel { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Update feedback status — PATCH /api/admin/v1/feedback/{id}. +/// +/// Body: `{status}` where status ∈ {"new","reviewed","archived"}. +#[tauri::command] +pub async fn admin_patch_feedback( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "feedback id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackPatch { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = patch_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// List operators — GET /api/admin/v1/operators. +/// +/// Operator-only. Returns all effective principals with `effectiveRole` and +/// `sources[]` (`config`, `owner_fallback`, `db`). +#[tauri::command] +pub async fn admin_list_operators( + origin: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let url = origin.route_url( + &routes::AdminRoute::OperatorsList, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Add or update an operator — PUT /api/admin/v1/operators/{pubkey}. +/// +/// Operator-only. Body: `{role}` where role ∈ {"operator","moderator"}. +/// Returns 409 if the pubkey is config-backed (immutable via API). +#[tauri::command] +pub async fn admin_put_operator( + origin: String, + pubkey: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let pubkey = + routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; + let url = origin.route_url( + &routes::AdminRoute::OperatorPut { pubkey }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = put_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Remove an operator — DELETE /api/admin/v1/operators/{pubkey}. +/// +/// Operator-only. Returns 409 if the pubkey is config-backed. +#[tauri::command] +pub async fn admin_delete_operator( + origin: String, + pubkey: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let pubkey = + routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; + let url = origin.route_url( + &routes::AdminRoute::OperatorDelete { pubkey }, + &routes::AdminQuery::default(), + ); + let bytes = delete_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Fetch a feedback attachment by SHA-256 hash for in-app preview. +/// +/// The front-end MUST supply `expectedMime` and `expectedSize` from the +/// server-validated `imeta` fields returned by `admin_get_feedback`; see +/// [`attachment::fetch_feedback_attachment`] for the checks applied. +/// +/// Returns `tauri::ipc::Response` so bytes cross IPC as a raw `ArrayBuffer`. +#[tauri::command] +pub async fn admin_fetch_feedback_attachment( + origin: String, + feedback_id: String, + sha256: String, + expected_mime: String, + expected_size: u64, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let keys = state.signing_keys()?; + attachment::fetch_feedback_attachment( + &origin, + &feedback_id, + &sha256, + &expected_mime, + expected_size, + &keys, + helpers::AttachmentUse::Preview, + ) + .await + .map(tauri::ipc::Response::new) +} + +/// Save a feedback attachment to a user-chosen path via the native save dialog. +/// +/// Fetches through the same validated path as preview, so non-image bytes are +/// never left as an in-memory blob URL (a WKWebView no-op for ``). +/// Returns `Ok(true)` when the file was written, `Ok(false)` when the user +/// cancelled the dialog. +#[tauri::command] +pub async fn admin_save_attachment( + origin: String, + feedback_id: String, + sha256: String, + expected_mime: String, + expected_size: u64, + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let keys = state.signing_keys()?; + attachment::save_feedback_attachment( + &origin, + &feedback_id, + &sha256, + &expected_mime, + expected_size, + &keys, + |name, filter, ext| async move { + crate::commands::export_util::pick_save_path(&app, &name, filter, &[ext]).await + }, + ) + .await +} + +// ── Member restrictions ─────────────────────────────────────────────────── + +/// Shown by the UI when a restriction call targets a relay other than the one +/// its list loaded from. +const RELAY_SCOPE_CHANGED: &str = + "active community changed since restrictions loaded; nothing was sent. Reload to continue."; + +/// Build a restrictions-route URL scoped to the active relay's community. +/// +/// The community is named by the active relay's host authority — the same +/// `relay_url_authority` the relay's own connection binding uses — so the relay +/// resolves its tenant. The desktop's local community ids are never sent. +/// +/// `expected_relay` is the relay the caller's restriction list loaded from. The +/// native relay is read once; if it no longer matches, the call fails before +/// any request so a workspace switch cannot retarget a page or removal. +fn restrictions_url( + origin: &str, + route: &routes::AdminRoute, + cursor: Option, + expected_relay: &str, + state: &crate::app_state::AppState, +) -> Result { + let origin = origin::AdminOrigin::parse(origin)?; + let relay_base = crate::relay::relay_api_base_url_with_override(state); + if expected_relay.trim().is_empty() + || crate::relay::assert_expected_relay_scope(Some(expected_relay), &relay_base).is_err() + { + return Err(RELAY_SCOPE_CHANGED.to_string()); + } + let host = buzz_core_pkg::tenant::relay_url_authority(&relay_base); + if host.is_empty() { + return Err("admin_community_host_unresolved".to_string()); + } + let q = routes::AdminQuery { + community_host: Some(host), + cursor, + ..Default::default() + }; + Ok(origin.route_url(route, &q)) +} + +/// List active bans and timeouts for the active relay's community — +/// GET /api/admin/v1/members/restrictions?communityHost={host}[&cursor={token}]. +/// +/// Returns `{ items: [...], nextCursor: string|null }`. Pass the previous +/// page's `nextCursor` as `cursor` to fetch the next page (relay page size 200). +#[tauri::command] +pub async fn admin_list_restrictions( + origin: String, + cursor: Option, + expected_relay: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let url = restrictions_url( + &origin, + &routes::AdminRoute::MemberRestrictionsList, + cursor, + &expected_relay, + &state, + )?; + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Lift an active ban — DELETE /api/admin/v1/members/{pubkey}/ban?communityHost={host}. +/// +/// Returns 204 on success, 409 when no active ban exists for this member. +/// A 409 is surfaced as an `AdminMutationError` so the UI can handle it +/// gracefully ("no active ban"). +#[tauri::command] +pub async fn admin_lift_ban( + origin: String, + pubkey: String, + expected_relay: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result<(), AdminMutationError> { + let pubkey = + routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; + let url = restrictions_url( + &origin, + &routes::AdminRoute::MemberBanDelete { pubkey }, + None, + &expected_relay, + &state, + )?; + // 204 No Content: empty body is the success signal. delete_admin_json + // returns Ok(vec![]) for 204; we discard the bytes and return (). + let _bytes = delete_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + Ok(()) +} + +/// Lift an active timeout — DELETE /api/admin/v1/members/{pubkey}/timeout?communityHost={host}. +/// +/// Returns 204 on success, 409 when no active timeout exists for this member. +#[tauri::command] +pub async fn admin_lift_timeout( + origin: String, + pubkey: String, + expected_relay: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result<(), AdminMutationError> { + let pubkey = + routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; + let url = restrictions_url( + &origin, + &routes::AdminRoute::MemberTimeoutDelete { pubkey }, + None, + &expected_relay, + &state, + )?; + let _bytes = delete_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + Ok(()) +} + +// ── Origin storage commands ─────────────────────────────────────────────── + +/// Core storage logic for `get_admin_origin`, parameterised by data directory +/// and resolved pubkey hex; the file is scoped to the state's active relay host. +/// +/// Reads the per-pubkey-per-relay JSON file, reparses the stored origin through +/// `AdminOrigin::parse()`, and returns the canonical string. Returns `None` +/// when no file exists. On malformed/invalid content, removes the file and +/// returns `Err` so the caller can surface a visible setup error. +pub(crate) fn get_admin_origin_core( + data_dir: &std::path::Path, + pubkey_hex: &str, + state: &crate::app_state::AppState, +) -> Result, String> { + let path = admin_origin_path(data_dir, pubkey_hex, state); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("failed to read admin console origin: {e}"))?; + let stored: StoredAdminOrigin = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + let remove_result = std::fs::remove_file(&path); + return Err(match remove_result { + Ok(()) => format!("stored admin console origin is invalid (removed): {e}"), + Err(re) => format!( + "stored admin console origin is invalid (quarantine failed — {re}): {e}" + ), + }); + } + }; + match origin::AdminOrigin::parse(&stored.origin) { + Ok(o) => Ok(Some(o.as_str().to_string())), + Err(e) => { + let remove_result = std::fs::remove_file(&path); + Err(match remove_result { + Ok(()) => format!("stored admin console origin is invalid (removed): {e}"), + Err(re) => format!( + "stored admin console origin is invalid (quarantine failed — {re}): {e}" + ), + }) + } + } +} + +/// Core storage logic for `set_admin_origin`, parameterised by data directory +/// and resolved pubkey hex; the file is scoped to the state's active relay host. +/// +/// Validates and persists `raw_origin`. Pass `None` to clear. Returns the +/// canonical origin string on success, or `None` on clear. +pub(crate) fn set_admin_origin_core( + data_dir: &std::path::Path, + pubkey_hex: &str, + state: &crate::app_state::AppState, + raw_origin: Option, +) -> Result, String> { + use crate::managed_agents::storage::atomic_write_json_restricted; + let path = admin_origin_path(data_dir, pubkey_hex, state); + match raw_origin { + None => { + if path.exists() { + std::fs::remove_file(&path) + .map_err(|e| format!("failed to remove admin console origin: {e}"))?; + } + Ok(None) + } + Some(raw) => { + let canonical = origin::AdminOrigin::parse(&raw)?.as_str().to_string(); + let payload = serde_json::to_vec_pretty(&StoredAdminOrigin { + origin: canonical.clone(), + }) + .map_err(|e| format!("failed to serialise admin console origin: {e}"))?; + atomic_write_json_restricted(&path, &payload)?; + Ok(Some(canonical)) + } + } +} + +/// Return the persisted admin console origin for the active pubkey and connected +/// relay, or `None` if none has been saved yet. +/// +/// The origin is stored per `(pubkey, relay_host)` so a prod-connected build +/// never reads a staging origin and vice versa. `expected_pubkey` is checked +/// against the active signing key before reading as a defence-in-depth guard +/// against delayed IPC from a prior session. +/// +/// The stored value is reparsed through `AdminOrigin::parse()` on every read. +/// If the stored content is invalid, it is removed and an error returned so +/// the settings card shows a visible setup error rather than silently degrading. +#[tauri::command] +pub fn get_admin_origin( + expected_pubkey: Option, + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + // Fail closed: never derive the pubkey from an error fallback. + let pubkey = validate_pubkey_hex(state.signing_keys()?.public_key().to_hex())?; + // If the caller supplied an expected pubkey, reject when it no longer + // matches the active key — a delayed IPC from a prior session. + if let Some(ref expected) = expected_pubkey { + if *expected != pubkey { + return Err( + "admin origin read rejected: active identity changed since request was sent" + .to_string(), + ); + } + } + use tauri::Manager as _; + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create app data dir: {e}"))?; + get_admin_origin_core(&dir, &pubkey, &state) +} + +/// Validate and persist the admin console origin for the active pubkey and +/// connected relay. +/// +/// The origin is stored per `(pubkey, relay_host)` so prod and staging builds +/// never share or overwrite each other's saved origin. `expected_pubkey` guards +/// against delayed IPC: if the active signing key no longer matches +/// `expected_pubkey`, the write is rejected. +/// +/// Passes `raw_origin` through `AdminOrigin::parse` to normalise and validate +/// it before writing. Pass `None` to clear the stored origin. +#[tauri::command] +pub fn set_admin_origin( + raw_origin: Option, + expected_pubkey: Option, + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + // Fail closed: never derive the pubkey from an error fallback. + let pubkey = validate_pubkey_hex(state.signing_keys()?.public_key().to_hex())?; + if let Some(ref expected) = expected_pubkey { + if *expected != pubkey { + return Err( + "admin origin write rejected: active identity changed since request was sent" + .to_string(), + ); + } + } + use tauri::Manager as _; + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create app data dir: {e}"))?; + set_admin_origin_core(&dir, &pubkey, &state, raw_origin) +} + +/// On-disk shape for the persisted admin console origin. +#[derive(serde::Serialize, serde::Deserialize)] +struct StoredAdminOrigin { + origin: String, +} + +/// Validate that `hex` is exactly 64 lowercase hexadecimal characters. +/// +/// `nostr::Keys::public_key().to_hex()` always produces this form, but this +/// check serves as a defence-in-depth guard against future API changes or +/// unexpected fallbacks that could produce a non-canonical string and silently +/// corrupt the filename-based per-pubkey namespace. +fn validate_pubkey_hex(hex: String) -> Result { + if hex.len() == 64 && hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + Ok(hex) + } else { + Err("signing key produced an unexpected pubkey format; cannot scope storage".to_string()) + } +} + +/// Origin file for `(pubkey, active relay host)`. Origins saved under the +/// pre-per-relay name (`admin-console-origin-{pubkey}.json`) are not migrated. +fn admin_origin_path( + data_dir: &std::path::Path, + pubkey_hex: &str, + state: &crate::app_state::AppState, +) -> std::path::PathBuf { + let relay_slug = relay_host_slug(state); + data_dir.join(format!( + "admin-console-origin-{pubkey_hex}-{relay_slug}.json" + )) +} + +/// Derive a safe filename slug from the connected relay's host. +/// +/// Extracts the host from the active relay HTTP base URL (e.g. +/// `https://relay.example.com` → `relay.example.com`) and strips any +/// characters that are not safe in filenames across all platforms. +/// Falls back to `"default"` on a malformed or absent URL so storage +/// never breaks during relay bootstrapping. +fn relay_host_slug(state: &crate::app_state::AppState) -> String { + let base = crate::relay::relay_api_base_url_with_override(state); + // `Url` lowercases DNS hosts and canonicalizes IPv6 literals, so every + // spelling of one address yields one slug. The port is dropped on + // purpose: `relay.example.com:8080` and `relay.example.com` share an entry. + match url::Url::parse(&base) + .ok() + .and_then(|u| u.host().map(|h| h.to_owned())) + { + // `_` never appears in a DNS slug, so IPv6 slugs cannot collide with + // one; all eight segments are kept, so they cannot collide with each other. + Some(url::Host::Ipv6(addr)) => format!( + "ip6_{}", + addr.segments() + .iter() + .map(|seg| format!("{seg:x}")) + .collect::>() + .join("-") + ), + Some(url::Host::Ipv4(addr)) => addr.to_string(), + Some(url::Host::Domain(host)) => { + // Retain only hostname-safe chars so the slug is filename-safe. + let slug: String = host + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '.' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + let slug = slug.trim_matches('-'); + if slug.is_empty() { + "default".to_string() + } else { + slug.to_string() + } + } + None => "default".to_string(), + } +} + +// ── NIP-11 admin-origin discovery ───────────────────────────────────────── + +mod discovery; + +/// A discovered admin console origin plus its same-host trust binding. +/// +/// Serialised for the webview as `{ origin, sameHost }`. `sameHost` is `true` +/// only when the advertised origin's host matches the connected relay's host; +/// the TypeScript layer auto-saves + auto-probes a same-host origin but treats a +/// cross-host advertisement (`sameHost == false`) as pre-fill-only, so the +/// operator's key never signs a NIP-98 challenge against an unrelated, +/// relay-advertised host without explicit confirmation. See `discovery.rs` for +/// the full trust model. +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveredAdminOrigin { + origin: String, + same_host: bool, +} + +/// Auto-discover the admin console origin from the connected relay's NIP-11 +/// document. Returns the canonical origin and its `sameHost` flag when the relay +/// advertises a valid `admin_api` that does not resolve to a private/reserved +/// target, or `None` otherwise. +/// +/// `sameHost` binds the discovered origin to the connected relay's host: a +/// same-host origin is auto-saved and auto-probed by design (intentional +/// first-mount UX), while a cross-host advertisement is surfaced only as a +/// pre-fill the operator must explicitly save. This prevents a malicious relay +/// from advertising an attacker-controlled `admin_api` and harvesting an +/// unconsented NIP-98 signature the moment `admin_probe` runs; residual exposure +/// is bounded because the NIP-98 header binds the exact URL, method, and payload. +/// Mirrors the native NIP-11 fetch used by `relay_requires_membership`. +#[tauri::command] +pub async fn admin_discover_origin( + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + let base = crate::relay::relay_api_base_url_with_override(&state); + discovery::discover_admin_origin_at(&state.http_client, &base).await +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/admin/mod_tests.rs b/desktop/src-tauri/src/commands/admin/mod_tests.rs new file mode 100644 index 0000000000..d6dd3df706 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/mod_tests.rs @@ -0,0 +1,1389 @@ +//! Unit and integration tests for `commands/admin/mod.rs` (split to keep `mod.rs` +//! under the 1000-line file-size ratchet). +//! +//! Included via `#[path = "mod_tests.rs"] mod tests;` at the bottom of `mod.rs`, +//! so `use super::*` gives access to all items in that module. + +use super::*; +use crate::commands::admin::{origin::AdminOrigin, routes::AdminRoute}; +use std::sync::Arc; + +/// Type alias for the request inspector closure passed to `serve_sequence_inspect`. +type RequestInspector = std::sync::Arc; + +/// Parsed HTTP request data for transport-layer assertions. +#[derive(Debug)] +struct RequestRecord { + method: String, + path: String, + auth: Option, +} + +// ── AdminOrigin × routes integration ───────────────────────────────────── + +#[test] +fn reports_list_url_contains_api_prefix() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportsList, &routes::AdminQuery::default()); + assert!( + url.starts_with("https://admin.example.com/api/admin/v1/"), + "URL must include /api/admin/v1/ prefix: {url}" + ); +} + +#[test] +fn localhost_uses_http_prefix() { + let o = AdminOrigin::parse("http://localhost:3000").unwrap(); + let url = o.route_url(&AdminRoute::FeedbackList, &routes::AdminQuery::default()); + assert!(url.starts_with("http://localhost:3000/api/admin/v1/")); +} + +// ── parse_probe ─────────────────────────────────────────────────────────── + +/// A well-formed `/probe` response body. `role`/`source` are JSON literals +/// (`"operator"`, `null`, …) so the helper can build both nip98 and +/// token/disabled shapes. +fn probe_json(auth_mode: &str, role: &str, source: &str, can_act: bool, can_staff: bool) -> String { + format!( + r#"{{"status":"ok","authMode":"{auth_mode}","role":{role},"source":{source},"canAct":{can_act},"canStaff":{can_staff}}}"# + ) +} + +#[test] +fn parse_probe_rejects_invalid_inputs() { + // Table of structural rejection cases. Each row is (content_type, body_bytes, label). + // Non-JSON content type, missing required field, wrong-typed field, and non-object bodies + // must all return None — an unrelated endpoint must not classify as the admin API. + let well_formed = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let cases: &[(&str, &[u8], &str)] = &[ + ("text/html", well_formed.as_bytes(), "non-JSON content type (text/html)"), + ("", well_formed.as_bytes(), "non-JSON content type (empty)"), + // Missing `canStaff` — an unrelated JSON endpoint must not classify as admin API. + ("application/json", + br#"{"status":"ok","authMode":"nip98","role":"operator","source":"config","canAct":true}"#, + "missing required field canStaff"), + // `canAct` as a string, not a bool. + ("application/json", + br#"{"status":"ok","authMode":"nip98","role":"operator","source":"config","canAct":"yes","canStaff":true}"#, + "wrong-typed field canAct"), + ("application/json", b"[]", "non-object: array"), + ("application/json", b"\"string\"", "non-object: string"), + ("application/json", b"not json", "non-object: invalid JSON"), + ]; + for (ct, body, label) in cases { + assert!( + parse_probe(ct, body).is_none(), + "row must be rejected: {label}" + ); + } +} + +// ── authorized_principal / is_coherent_disabled invariants ──────────────── +// +// Structural deserialisation (parse_probe) is necessary but not sufficient: +// a 2xx body must also satisfy the full relay contract before its state is +// trusted. These pin every branch of that invariant. + +/// Parse a body known to be structurally valid, then validate it. +fn authorized(body: &str) -> Option<(ProbeRole, ProbeSource)> { + parse_probe("application/json", body.as_bytes()) + .expect("structurally valid probe") + .authorized_principal() +} + +#[test] +fn authorized_principal_accepts_every_coherent_shape() { + // Operator (canStaff true) and moderator (canStaff false) across all three + // recognised sources — each must yield the typed principal. + let cases: &[(String, ProbeRole, ProbeSource)] = &[ + ( + probe_json("nip98", r#""operator""#, r#""config""#, true, true), + ProbeRole::Operator, + ProbeSource::Config, + ), + ( + probe_json("nip98", r#""operator""#, r#""owner_fallback""#, true, true), + ProbeRole::Operator, + ProbeSource::OwnerFallback, + ), + ( + probe_json("nip98", r#""moderator""#, r#""db""#, true, false), + ProbeRole::Moderator, + ProbeSource::Db, + ), + ]; + for (body, role, source) in cases { + assert_eq!(authorized(body), Some((*role, *source))); + } +} + +#[test] +fn authorized_principal_rejects_every_incoherent_shape() { + // Structurally valid probe bodies the relay never emits under nip98; + // accepting any is fail-open. One invariant broken per row, top to bottom: + // wrong status, non-nip98 authMode, missing role, unknown role, missing + // source, unknown source, false canAct, operator lacking canStaff, + // moderator carrying canStaff. + let pj = probe_json; + let cases = [ + pj("nip98", r#""operator""#, r#""config""#, true, true) + .replace(r#""status":"ok""#, r#""status":"error""#), + pj("token", "null", "null", false, false), + pj("nip98", "null", r#""config""#, true, true), + pj("nip98", r#""superuser""#, r#""config""#, true, true), + pj("nip98", r#""operator""#, "null", true, true), + pj("nip98", r#""operator""#, r#""ldap""#, true, true), + pj("nip98", r#""operator""#, r#""config""#, false, true), + pj("nip98", r#""operator""#, r#""config""#, true, false), + pj("nip98", r#""moderator""#, r#""config""#, true, true), + ]; + for (i, body) in cases.iter().enumerate() { + assert_eq!(authorized(body), None, "row {i} must be rejected"); + } +} + +#[test] +fn is_coherent_disabled_accepts_only_canonical_disabled() { + // Canonical disabled authorizes; nip98 mode and any disabled body claiming + // a role/source or a capability is incoherent and must be rejected. + let disabled = probe_json("disabled", "null", "null", false, false); + assert!(parse_probe("application/json", disabled.as_bytes()) + .unwrap() + .is_coherent_disabled()); + + let incoherent = [ + probe_json("nip98", r#""operator""#, r#""config""#, true, true), + probe_json("disabled", r#""operator""#, "null", false, false), + probe_json("disabled", "null", "null", true, false), + ]; + for body in &incoherent { + assert!(!parse_probe("application/json", body.as_bytes()) + .unwrap() + .is_coherent_disabled()); + } +} + +// ── Storage core through production code ───────────────────────────────── +// +// All tests call `get_admin_origin_core` / `set_admin_origin_core` directly +// — the `pub(crate)` functions parameterised by data directory, pubkey +// hex, and relay slug. No `tauri::State` needed; each test uses a `tempdir` +// for isolation. + +const TEST_RELAY: &str = "relay.example.com"; + +/// App state whose active relay is `url`, exercising the production +/// relay-host scoping of the origin file. +fn relay_state(url: &str) -> crate::app_state::AppState { + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(url.to_string()); + state +} + +fn test_relay() -> crate::app_state::AppState { + relay_state("wss://relay.example.com") +} + +#[test] +fn storage_round_trip_returns_canonical_origin() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "a".repeat(64); + let origin = "https://admin.example.com"; + let canonical = + set_admin_origin_core(dir.path(), &pubkey, &test_relay(), Some(origin.to_string())) + .unwrap() + .unwrap(); + assert!( + canonical.starts_with("https://admin.example.com"), + "canonical origin must start with the input origin: {canonical}" + ); + let read_back = get_admin_origin_core(dir.path(), &pubkey, &test_relay()) + .unwrap() + .unwrap(); + assert_eq!( + canonical, read_back, + "read-back must match the canonical form returned by set" + ); +} + +#[test] +fn storage_two_identities_are_isolated() { + let dir = tempfile::tempdir().unwrap(); + let pubkey_a = "a".repeat(64); + let pubkey_b = "b".repeat(64); + set_admin_origin_core( + dir.path(), + &pubkey_a, + &test_relay(), + Some("https://admin-a.example.com".to_string()), + ) + .unwrap(); + set_admin_origin_core( + dir.path(), + &pubkey_b, + &test_relay(), + Some("https://admin-b.example.com".to_string()), + ) + .unwrap(); + + let a = get_admin_origin_core(dir.path(), &pubkey_a, &test_relay()) + .unwrap() + .unwrap(); + let b = get_admin_origin_core(dir.path(), &pubkey_b, &test_relay()) + .unwrap() + .unwrap(); + assert!( + a.contains("admin-a"), + "pubkey_a must read its own origin: {a}" + ); + assert!( + b.contains("admin-b"), + "pubkey_b must read its own origin: {b}" + ); + // No cross-read: each key sees only its own value. + assert!( + !a.contains("admin-b"), + "pubkey_a must not read pubkey_b's origin" + ); + assert!( + !b.contains("admin-a"), + "pubkey_b must not read pubkey_a's origin" + ); +} + +#[test] +fn storage_two_relays_are_isolated() { + // Same pubkey — different relay slugs must not share storage. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "a".repeat(64); + set_admin_origin_core( + dir.path(), + &pubkey, + &relay_state("wss://prod.example.com"), + Some("https://admin-prod.example.com".to_string()), + ) + .unwrap(); + set_admin_origin_core( + dir.path(), + &pubkey, + &relay_state("wss://staging.example.com:8443"), + Some("https://admin-staging.example.com".to_string()), + ) + .unwrap(); + + let prod = get_admin_origin_core( + dir.path(), + &pubkey, + &relay_state("https://prod.example.com"), + ) + .unwrap() + .unwrap(); + let staging = get_admin_origin_core( + dir.path(), + &pubkey, + &relay_state("wss://Staging.Example.com/ws"), + ) + .unwrap() + .unwrap(); + assert!( + prod.contains("admin-prod"), + "prod relay must read its own origin: {prod}" + ); + assert!( + staging.contains("admin-staging"), + "staging relay must read its own origin: {staging}" + ); + assert!( + !prod.contains("staging"), + "prod relay must not read staging origin" + ); + assert!( + !staging.contains("prod"), + "staging relay must not read prod origin" + ); + // prod's file must not exist under the staging slug. + let absent = + get_admin_origin_core(dir.path(), &pubkey, &relay_state("wss://other.example.com")) + .unwrap(); + assert_eq!(absent, None, "unknown relay slug must return None"); +} + +#[test] +fn storage_malformed_json_is_quarantined_and_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "c".repeat(64); + // Write a corrupt file directly — bypassing set_admin_origin_core. + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}-{TEST_RELAY}.json")); + std::fs::write(&path, b"not valid json").unwrap(); + assert!(path.exists(), "corrupt file must exist before read"); + + let result = get_admin_origin_core(dir.path(), &pubkey, &test_relay()); + assert!( + result.is_err(), + "malformed JSON must return Err: {result:?}" + ); + // Quarantine: the file must have been removed. + assert!( + !path.exists(), + "quarantine failed: corrupt file must be removed after error" + ); +} + +#[test] +fn storage_forbidden_path_bearing_origin_is_quarantined_and_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "d".repeat(64); + // Write a file whose stored origin contains a path component — + // AdminOrigin::parse must reject it, triggering quarantine. + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}-{TEST_RELAY}.json")); + let payload = serde_json::json!({ "origin": "https://admin.example.com/forbidden/path" }); + std::fs::write(&path, serde_json::to_vec(&payload).unwrap()).unwrap(); + assert!(path.exists(), "seeded file must exist before read"); + + let result = get_admin_origin_core(dir.path(), &pubkey, &test_relay()); + assert!( + result.is_err(), + "origin with path must return Err on reparse: {result:?}" + ); + assert!( + !path.exists(), + "quarantine failed: forbidden-origin file must be removed after error" + ); +} + +#[test] +fn storage_clear_removes_file() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "e".repeat(64); + set_admin_origin_core( + dir.path(), + &pubkey, + &test_relay(), + Some("https://admin.example.com".to_string()), + ) + .unwrap(); + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}-{TEST_RELAY}.json")); + assert!(path.exists(), "file must exist after set"); + + let result = set_admin_origin_core(dir.path(), &pubkey, &test_relay(), None).unwrap(); + assert_eq!(result, None, "clear must return None"); + assert!(!path.exists(), "clear must remove the file"); +} + +#[test] +fn storage_no_file_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "f".repeat(64); + let result = get_admin_origin_core(dir.path(), &pubkey, &test_relay()).unwrap(); + assert_eq!(result, None, "absent file must return None"); +} + +// ── validate_pubkey_hex ─────────────────────────────────────────────────── + +#[test] +fn validate_pubkey_hex_cases() { + // Direct assertions: valid input passes; uppercase, empty, and wrong-length inputs fail. + assert!( + validate_pubkey_hex("a".repeat(64)).is_ok(), + "64 lowercase hex chars must pass" + ); + assert!( + validate_pubkey_hex("A".repeat(64)).is_err(), + "uppercase hex must be rejected" + ); + assert!( + validate_pubkey_hex("".to_string()).is_err(), + "empty string must be rejected" + ); + assert!( + validate_pubkey_hex("a".repeat(63)).is_err(), + "63 chars must be rejected" + ); +} + +// ── relay_host_slug ─────────────────────────────────────────────────────── + +fn slug(url: &str) -> String { + relay_host_slug(&relay_state(url)) +} + +#[test] +fn relay_host_slug_strips_scheme_port_and_path() { + assert_eq!(slug("wss://relay.example.com"), "relay.example.com"); + assert_eq!(slug("wss://relay.example.com:443/ws"), "relay.example.com"); + assert_eq!(slug("ws://staging.example.com:8080"), "staging.example.com"); +} + +#[test] +fn relay_host_slug_normalises_to_lowercase() { + assert_eq!(slug("wss://Relay.EXAMPLE.COM"), "relay.example.com"); +} + +#[test] +fn relay_host_slug_two_relays_produce_distinct_slugs() { + assert_ne!( + slug("wss://relay.prod.example.com"), + slug("wss://relay.staging.example.com") + ); +} + +#[test] +fn relay_host_slug_keeps_distinct_ipv6_relays_apart() { + // The old `split(':')` port-drop kept only `[2001` for both. + assert_ne!( + slug("wss://[2001:db8::1]"), + slug("wss://[2001:db8::2]:8443") + ); + assert_eq!(slug("wss://[2001:db8::1]"), "ip6_2001-db8-0-0-0-0-0-1"); +} + +#[test] +fn relay_host_slug_ipv6_never_matches_a_dns_slug() { + // A DNS host spelled like the IPv6 slug still maps elsewhere. + assert_ne!( + slug("wss://[2001:db8::1]"), + slug("wss://ip6-2001-db8-0-0-0-0-0-1.example") + ); + assert!(!slug("wss://ip6_2001-db8-0-0-0-0-0-1").starts_with("ip6_")); +} + +#[test] +fn storage_ipv6_relays_are_isolated_and_spelling_independent() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "a".repeat(64); + set_admin_origin_core( + dir.path(), + &pubkey, + &relay_state("wss://[2001:db8::1]"), + Some("https://admin-a.example.com".to_string()), + ) + .unwrap(); + + // Same first group, different address: nothing saved for it. + assert_eq!( + get_admin_origin_core(dir.path(), &pubkey, &relay_state("wss://[2001:db8::2]")).unwrap(), + None + ); + // Expanded, upper-case, with port: the same address reads the same file. + let same = get_admin_origin_core( + dir.path(), + &pubkey, + &relay_state("wss://[2001:0DB8:0:0:0:0:0:1]:8443/ws"), + ) + .unwrap() + .unwrap(); + assert!(same.contains("admin-a"), "{same}"); + + // Clearing through the equivalent spelling removes that one file. + set_admin_origin_core( + dir.path(), + &pubkey, + &relay_state("wss://[2001:db8:0::1]"), + None, + ) + .unwrap(); + assert_eq!( + get_admin_origin_core(dir.path(), &pubkey, &relay_state("wss://[2001:db8::1]")).unwrap(), + None + ); +} + +#[test] +fn storage_ignores_a_file_saved_under_the_old_ambiguous_ipv6_slug() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "a".repeat(64); + std::fs::write( + dir.path() + .join(format!("admin-console-origin-{pubkey}-2001.json")), + r#"{"origin":"https://admin-other.example.com"}"#, + ) + .unwrap(); + assert_eq!( + get_admin_origin_core(dir.path(), &pubkey, &relay_state("wss://[2001:db8::1]")).unwrap(), + None + ); +} + +// ── Live stub helpers ───────────────────────────────────────────────────── + +/// Serve sequential HTTP responses from a background thread. +/// +/// For each request the listener reads the raw HTTP bytes, calls the +/// provided inspector closure with the raw request bytes and slot index, +/// then sends the pre-configured response. The inspector records request +/// details post-hoc for assertion after the probe completes. +async fn serve_sequence_inspect( + responses: Vec<(&'static str, &'static str, &'static str)>, + inspect: Option, +) -> std::net::SocketAddr { + use std::io::{Read, Write}; + client::init_admin_client().expect("client builds"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for (idx, (status, headers, body)) in responses.into_iter().enumerate() { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + // Invoke the inspector with the raw request bytes. + if let Some(ref f) = inspect { + f(idx, &buf[..n]); + } + let body_bytes = body.as_bytes(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{headers}Connection: close\r\n\r\n", + body_bytes.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body_bytes); + let _ = stream.flush(); + } + } + }); + addr +} + +/// Serve sequential responses without request inspection (backward compat). +async fn serve_sequence( + responses: Vec<(&'static str, &'static str, &'static str)>, +) -> std::net::SocketAddr { + serve_sequence_inspect(responses, None).await +} + +/// Serve a two-slot NIP-98 stub where the second response is gated on the +/// received Authorization header matching `expected_token`. +/// +/// Slot 0: always 401 Unauthorized + `WWW-Authenticate: Nostr` (triggers retry). +/// Slot 1: 200 OK with JSON body if the received Authorization header equals +/// `expected_token`; plain 401 (no Nostr challenge) otherwise — a mismatch +/// means the production header call was missing, so the probe returns +/// Nip98Denied and the caller's `Nip98Authorized` assertion fails. +/// +/// Both slots are recorded in the returned `Arc>>`. +async fn serve_gated_nip98( + expected_token: String, + authorized_body: &'static str, +) -> ( + std::net::SocketAddr, + Arc>>, +) { + use std::io::{Read, Write}; + client::init_admin_client().expect("client builds"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let records: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let records_bg = Arc::clone(&records); + std::thread::spawn(move || { + for slot in 0..2usize { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let text = std::str::from_utf8(&buf[..n]).unwrap_or(""); + // Parse request line and Authorization header. + let first_line = text.lines().next().unwrap_or(""); + let mut parts = first_line.splitn(3, ' '); + let method = parts.next().unwrap_or("").to_string(); + let path = parts.next().unwrap_or("").to_string(); + let auth = text + .lines() + .find(|l| l.to_ascii_lowercase().starts_with("authorization:")) + .map(|l| l[l.find(':').unwrap() + 1..].trim().to_string()); + records_bg.lock().unwrap().push(RequestRecord { + method, + path, + auth: auth.clone(), + }); + // Gate: slot 0 always challenges; slot 1 returns 200 only on + // header match, 401 (no challenge) otherwise. + let (status, headers, body): (&str, &str, &str) = if slot == 0 { + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", "") + } else if auth.as_deref() == Some(expected_token.as_str()) { + ( + "200 OK", + "Content-Type: application/json\r\n", + authorized_body, + ) + } else { + // Mismatch or absent header → plain 401 (no Nostr challenge). + // admin_probe_inner sees a non-Nostr 401 after the retry and + // returns Nip98Denied, causing the caller's Nip98Authorized + // assertion to fail — which is the intended mutation catch. + ("401 Unauthorized", "", "") + }; + let resp = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{headers}Connection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); + } + } + }); + (addr, records) +} + +// ── admin_probe_inner end-to-end state machine ──────────────────────────── + +#[tokio::test] +async fn probe_inner_simple_response_classifications() { + // Table of single-response probe outcomes. Each row: (status, content_type, + // body, expected_result_label). Multi-step sequences (persistent-401, + // NIP-98 challenge/authorized) stay as dedicated tests below. + // + // Rows: + // html-200: text/html intercept page → NetworkOrIntercepted + // malformed-json-200: application/json malformed body → NotAdminApi + // disabled-200: canonical disabled probe → Disabled + // nip98-200-no-auth: nip98 authMode without 401 challenge is a contract + // violation → NotAdminApi (must not classify as Disabled) + // garbage-json-200: valid JSON but not a probe envelope → NotAdminApi + + let disabled_body = probe_json("disabled", "null", "null", false, false); + let disabled_static: &'static str = Box::leak(disabled_body.into_boxed_str()); + let nip98_body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let nip98_static: &'static str = Box::leak(nip98_body.into_boxed_str()); + + struct Row { + label: &'static str, + status: &'static str, + ct: &'static str, + body: &'static str, + is_match: fn(&AdminProbeResult) -> bool, + } + + let rows = [ + Row { + label: "html-200 → NetworkOrIntercepted", + status: "200 OK", + ct: "Content-Type: text/html; charset=utf-8\r\n", + body: "sign in", + is_match: |r| matches!(r, AdminProbeResult::NetworkOrIntercepted), + }, + Row { + label: "malformed-json-200 → NotAdminApi", + status: "200 OK", + ct: "Content-Type: application/json\r\n", + body: "not valid json", + is_match: |r| matches!(r, AdminProbeResult::NotAdminApi), + }, + Row { + label: "disabled-200 → Disabled", + status: "200 OK", + ct: "Content-Type: application/json\r\n", + body: disabled_static, + is_match: |r| matches!(r, AdminProbeResult::Disabled), + }, + Row { + label: "nip98-authmode-200-without-auth → NotAdminApi", + status: "200 OK", + ct: "Content-Type: application/json\r\n", + body: nip98_static, + is_match: |r| matches!(r, AdminProbeResult::NotAdminApi), + }, + Row { + label: "garbage-json-200 → NotAdminApi", + status: "200 OK", + ct: "Content-Type: application/json\r\n", + body: "[1,2,3]", + is_match: |r| matches!(r, AdminProbeResult::NotAdminApi), + }, + ]; + + for row in &rows { + let addr = serve_sequence(vec![(row.status, row.ct, row.body)]).await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!( + (row.is_match)(&result), + "row must match expected classification: {}", + row.label + ); + } +} + +#[tokio::test] +async fn probe_inner_persistent_401_is_nip98_denied() { + let addr = serve_sequence(vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ("401 Unauthorized", "", ""), + ]) + .await; + let sign = |_url: &str| -> Result { Ok("Nostr dGVzdA==".to_string()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Nip98Denied)); +} + +#[tokio::test] +async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_auth_header() { + // Verifies: + // 1. probe state machine produces Nip98Authorized on a Nostr 401→200 sequence. + // 2. The second request carries an Authorization header equal to the signing + // closure's token — tested by the gated stub: slot 1 returns 200 only + // when the received Authorization header matches the expected token; any + // mismatch or absent header returns a plain 401, making the state machine + // return Nip98Denied and failing the Nip98Authorized assertion. + // 3. The first request carries no Authorization header. + // 4. Deleting the `.header(AUTHORIZATION, …)` production line causes the + // stub to receive no header on slot 1, return 401, and the test fails. + + let expected_token = "Nostr dGVzdA==".to_string(); + let expected_token_for_sign = expected_token.clone(); + + let valid_body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let valid_body_static: &'static str = Box::leak(valid_body.into_boxed_str()); + + // serve_gated_nip98: slot 0 always challenges; slot 1 checks the Authorization + // header and returns 200 on match, 401 on mismatch/absent. + let (addr, records) = serve_gated_nip98(expected_token, valid_body_static).await; + + let sign = move |_url: &str| -> Result { Ok(expected_token_for_sign.clone()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + + // The relay-resolved role/source must be carried through to the UI so the + // Staffing tab renders for an operator. + assert!( + matches!( + &result, + AdminProbeResult::Nip98Authorized { role, source } + if role.as_deref() == Some("operator") && source.as_deref() == Some("config") + ), + "expected Nip98Authorized operator/config, got {result:?}" + ); + + let records = records.lock().unwrap(); + assert_eq!(records.len(), 2, "exactly two requests must have been made"); + + // Request 0: unauthenticated GET — no Authorization header. + assert_eq!( + records[0].method, "GET", + "slot-0 must be GET; got {:?}", + records[0].method + ); + assert!( + records[0].path.contains("/api/admin/v1/probe"), + "slot-0 must target the probe endpoint; got {:?}", + records[0].path + ); + assert!( + records[0].auth.is_none(), + "slot-0 must carry no Authorization; got {:?}", + records[0].auth + ); + + // Request 1: authenticated retry — Authorization must equal the signing token. + // The stub already enforced this (returned 200 only on match), so this + // post-hoc assertion documents the observed value for auditability. + assert_eq!( + records[1].method, "GET", + "slot-1 must be GET; got {:?}", + records[1].method + ); + assert!( + records[1].path.contains("/api/admin/v1/probe"), + "slot-1 must target the probe endpoint; got {:?}", + records[1].path + ); + assert_eq!( + records[1].auth.as_deref(), + Some("Nostr dGVzdA=="), + "slot-1 Authorization must equal the signing closure token" + ); +} + +#[tokio::test] +async fn probe_inner_missing_auth_header_fails_to_authorize() { + // Verifies the no-sign path: when no signing closure is provided and the + // server issues a Nostr challenge, admin_probe_inner returns Nip98Denied. + // The production code only calls `sign(url)?` when a signing closure is + // Some; passing None causes the signing step to be skipped entirely, so + // no Authorization header is attached and the probe returns Nip98Denied + // without making a second request. + let addr = serve_sequence(vec![( + "401 Unauthorized", + "WWW-Authenticate: Nostr\r\n", + "", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Nip98Denied)); +} + +#[tokio::test] +async fn probe_inner_authenticated_302_is_network_or_intercepted() { + let addr = serve_sequence(vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ( + "302 Found", + "Location: https://cloudflareaccess.com/\r\n", + "", + ), + ]) + .await; + let sign = |_url: &str| -> Result { Ok("Nostr dGVzdA==".to_string()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + assert!( + matches!(result, AdminProbeResult::NetworkOrIntercepted), + "authenticated 302 must be NetworkOrIntercepted, got {result:?}" + ); +} + +#[tokio::test] +async fn probe_inner_bearer_401_is_not_admin_api() { + let addr = serve_sequence(vec![( + "401 Unauthorized", + "WWW-Authenticate: Bearer realm=\"admin\"\r\n", + "", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + // Bearer is no longer a recognized Buzz admin mode; an unrecognized 401 + // challenge classifies as NotAdminApi. + assert!(matches!(result, AdminProbeResult::NotAdminApi)); +} + +// ── .localhost origin: end-to-end parse, route, connect ────────────────────── + +/// Verifies that `http://admin.localhost:` is accepted as a valid origin, +/// that the signing closure receives the preserved `admin.localhost` probe URL +/// (not rewritten to `127.0.0.1`), and that `ADMIN_CLIENT` actually routes +/// both requests in a NIP-98 sequence to the loopback listener. +/// +/// Sequence: slot-0 = `401 Unauthorized + WWW-Authenticate: Nostr` (forces the +/// sign closure to fire), slot-1 = `200 OK + authorized nip98 JSON`. This +/// exercises the full `admin_probe_inner` NIP-98 path via the production +/// `ADMIN_CLIENT` that carries `LocalhostDnsResolver`. +/// +/// Mutation evidence: +/// - Removing `.ends_with(".localhost")` from `is_loopback_host` in `origin.rs` +/// makes `AdminOrigin::parse` return `Err`; `admin_probe_inner` propagates +/// that as `Err` and the `.expect()` panics → RED before any network I/O. +/// - Removing the `.ends_with(".localhost")` branch from `LocalhostDnsResolver` +/// makes the connection time out on Linux/Windows CI (system GAI fails) → +/// `NetworkOrIntercepted`, not `Nip98Authorized` → the `matches!` assertion RED. +#[tokio::test] +async fn dot_localhost_origin_parses_and_probe_inner_reaches_loopback_via_nip98() { + use std::sync::{Arc, Mutex}; + + client::init_admin_client().expect("client builds"); + + // Serve: slot-0 = 401 Nostr challenge, slot-1 = 200 authorized nip98 response. + let probe_body = probe_json("nip98", "\"operator\"", "\"db\"", true, true); + let probe_body_static: &'static str = Box::leak(probe_body.into_boxed_str()); + + // Use serve_sequence_inspect to capture raw request bytes for Host assertions. + let captured: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let cap = Arc::clone(&captured); + + let addr = serve_sequence_inspect( + vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ( + "200 OK", + "Content-Type: application/json\r\n", + probe_body_static, + ), + ], + Some(Arc::new(move |_idx, bytes: &[u8]| { + cap.lock().unwrap().push(bytes.to_vec()); + })), + ) + .await; + + let port = addr.port(); + + // Capture the exact URL the signing closure receives — the production seam. + let signed_urls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let signed_urls_clone = Arc::clone(&signed_urls); + let sign = move |url: &str| -> Result { + signed_urls_clone.lock().unwrap().push(url.to_owned()); + Ok("Nostr dGVzdA==".to_string()) + }; + + let origin_str = format!("http://admin.localhost:{port}"); + let result = admin_probe_inner(&origin_str, Some(sign)) + .await + .expect("admin_probe_inner must succeed on a valid admin.localhost origin"); + + // The authorized 200 probe response must resolve to Nip98Authorized. + assert!( + matches!(result, AdminProbeResult::Nip98Authorized { .. }), + "admin.localhost NIP-98 probe must resolve to Nip98Authorized; got {result:?}", + ); + + // The sign closure must have been called exactly once (on the Nostr challenge). + let urls = signed_urls.lock().unwrap(); + assert_eq!( + urls.len(), + 1, + "sign closure must be called exactly once; got {} calls", + urls.len(), + ); + + // The URL received by the signing closure must preserve `admin.localhost` — not 127.0.0.1. + assert_eq!( + urls[0], + format!("http://admin.localhost:{port}/api/admin/v1/probe"), + "signing closure must receive the preserved admin.localhost URL", + ); + + // Both requests must have arrived at the listener (2 slots served). + let reqs = captured.lock().unwrap(); + assert_eq!( + reqs.len(), + 2, + "listener must receive exactly 2 requests (unauthenticated + authenticated); got {}", + reqs.len(), + ); + + // Both requests must carry `host: admin.localhost:` (case-insensitive header name). + // The invariant is that the Host *value* preserves `admin.localhost`, not `127.0.0.1`. + let expected_host_value = format!("admin.localhost:{port}"); + for (i, raw) in reqs.iter().enumerate() { + let text = std::str::from_utf8(raw).expect("request must be valid UTF-8"); + let lower = text.to_lowercase(); + assert!( + lower.contains(&format!("host: {expected_host_value}")), + "request {i} must carry Host: {expected_host_value}; got headers:\n{text}", + ); + } +} + +// ── build_admin_mutation_request wire shape (N8: typed bodyless DELETE) ──────── + +/// Decode the NIP-98 event embedded in a `Nostr ` Authorization value. +fn decode_nip98_event(auth_value: &str) -> serde_json::Value { + use base64::Engine as _; + let b64 = auth_value + .strip_prefix("Nostr ") + .expect("authorization header must be a NIP-98 `Nostr ` value"); + let json = base64::engine::general_purpose::STANDARD + .decode(b64) + .expect("NIP-98 payload must be valid base64"); + serde_json::from_slice(&json).expect("NIP-98 payload must be a JSON event") +} + +/// First value of the NIP-98 tag named `name`, if present. +fn nip98_tag<'a>(event: &'a serde_json::Value, name: &str) -> Option<&'a str> { + event["tags"].as_array()?.iter().find_map(|t| { + let arr = t.as_array()?; + if arr.first()?.as_str()? == name { + arr.get(1)?.as_str() + } else { + None + } + }) +} + +/// A bodyless DELETE built through the shared mutation helper must go on the +/// wire with NO `Content-Type` and NO body, and be NIP-98-signed over the empty +/// payload — byte-identical to the bare DELETE the relay verified before this +/// consolidation routed DELETE through `mutation_admin_json`. +/// +/// Mutation evidence: setting `Content-Type`/a body on the `None` branch of +/// `build_admin_mutation_request`, or signing over anything but `&[]`, flips one +/// of the three wire assertions RED. The request is sent through the production +/// `ADMIN_CLIENT`, so a reqwest default header injection would also be caught. +#[tokio::test] +async fn bodyless_delete_wire_is_bare_and_signs_empty_payload() { + use sha2::{Digest, Sha256}; + use std::sync::{Arc, Mutex}; + + client::init_admin_client().expect("client builds"); + let http_client = client::ADMIN_CLIENT.get().unwrap(); + + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap = Arc::clone(&captured); + let addr = serve_sequence_inspect( + vec![("200 OK", "Content-Type: application/json\r\n", "{}")], + Some(Arc::new(move |_idx, bytes: &[u8]| { + *cap.lock().unwrap() = bytes.to_vec(); + })), + ) + .await; + + let keys = nostr::Keys::generate(); + let url = format!( + "http://127.0.0.1:{}/api/admin/v1/operators/{}", + addr.port(), + "0".repeat(64) + ); + let resp = helpers::build_admin_mutation_request( + http_client, + &keys, + &reqwest::Method::DELETE, + &url, + None, + ) + .expect("bodyless request builds") + .send() + .await + .expect("request reaches the loopback listener"); + assert!(resp.status().is_success()); + + let raw = captured.lock().unwrap().clone(); + let text = std::str::from_utf8(&raw).expect("request is valid UTF-8"); + let (headers, body) = text.split_once("\r\n\r\n").unwrap_or((text, "")); + + assert!( + headers.starts_with("DELETE "), + "request must be a DELETE; got:\n{headers}" + ); + assert!( + !headers.to_lowercase().contains("content-type"), + "a bodyless DELETE must carry no Content-Type on the wire; got:\n{headers}" + ); + assert!( + body.is_empty(), + "a bodyless DELETE must carry no wire body; got body {body:?}" + ); + + let auth_value = headers + .lines() + .find(|l| l.to_lowercase().starts_with("authorization:")) + .and_then(|l| l.split_once(':')) + .map(|(_, v)| v.trim()) + .expect("NIP-98 Authorization header present"); + let event = decode_nip98_event(auth_value); + assert_eq!(nip98_tag(&event, "method"), Some("DELETE")); + assert_eq!(nip98_tag(&event, "u"), Some(url.as_str())); + assert_eq!( + nip98_tag(&event, "payload"), + Some(hex::encode(Sha256::digest(b"")).as_str()), + "bodyless request must sign over the empty payload" + ); +} + +/// The body-bearing branch of the same helper must instead declare +/// `application/json`, send the exact JSON bytes, and bind the NIP-98 `payload` +/// tag to the sha256 of those bytes — the contrast that makes the `Some`/`None` +/// split in `build_admin_mutation_request` falsifiable in both directions. +#[tokio::test] +async fn body_bearing_put_sets_content_type_and_signs_body() { + use sha2::{Digest, Sha256}; + + client::init_admin_client().expect("client builds"); + let http_client = client::ADMIN_CLIENT.get().unwrap(); + let keys = nostr::Keys::generate(); + let url = "https://admin.example.com/api/admin/v1/operators/0000000000000000000000000000000000000000000000000000000000000001"; + let body: &[u8] = br#"{"role":"moderator"}"#; + + let req = helpers::build_admin_mutation_request( + http_client, + &keys, + &reqwest::Method::PUT, + url, + Some(body), + ) + .expect("body-bearing request builds") + .build() + .expect("request is well-formed"); + + assert_eq!(req.method(), reqwest::Method::PUT); + assert_eq!( + req.headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "a body-bearing mutation must declare application/json" + ); + assert_eq!( + req.body().and_then(reqwest::Body::as_bytes), + Some(body), + "the exact JSON bytes must reach the wire" + ); + + let auth_value = req + .headers() + .get(reqwest::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .expect("NIP-98 Authorization header present"); + let event = decode_nip98_event(auth_value); + assert_eq!( + nip98_tag(&event, "payload"), + Some(hex::encode(Sha256::digest(body)).as_str()), + "body-bearing request must sign over the sha256 of the exact body" + ); +} + +// ── Discovery same-host binding ─────────────────────────────────────────── +// +// `advertised_host_matches_relay` is the production seam that decides whether a +// relay-advertised admin origin is trusted for auto-save + auto-probe. It gates +// the `same_host` flag returned by `discover_admin_origin_at`; the TypeScript +// layer only auto-probes (signing a NIP-98 header with the operator key) when +// that flag is set, so a mismatch here is the difference between offering a +// pre-fill and handing an attacker-advertised host an unconsented signature. + +#[test] +fn advertised_host_trust_binding() { + let cases = [ + // Positive: identical host → auto-probe permitted. + ( + "https://admin.example.com", + "https://admin.example.com", + true, + "identical host must bind for auto-probe", + ), + // Positive: port/path differ but host matches — operator may run the + // admin console on a different port and still be same-host-bound. + // (Scheme variation is not tested here; this fixture varies port only.) + ( + "https://admin.example.com:8443", + "https://admin.example.com/query", + true, + "host match must bind regardless of port or path", + ), + // Positive: case-only host variation — `AdminOrigin::parse` lowercases + // the advertised host; relay-URL side compared case-insensitively. + ( + "https://Admin.Example.Com", + "https://ADMIN.EXAMPLE.COM", + true, + "case-only differences must still bind the same host", + ), + // Negative: cross-host advertisement must NOT bind for auto-probe — + // a mismatch here would allow an attacker-advertised host to obtain a + // NIP-98 signed request with the operator's key. + ( + "https://attacker.example.com", + "https://admin.example.com", + false, + "a cross-host advertisement must not bind for auto-probe", + ), + ]; + for (advertised_url, relay_url, expected, label) in cases { + let advertised = AdminOrigin::parse(advertised_url).unwrap(); + assert_eq!( + discovery::advertised_host_matches_relay(&advertised, relay_url), + expected, + "{label}", + ); + } +} + +// ── Native Save core over the wire ──────────────────────────────────────── + +const SAVE_FEEDBACK_ID: &str = "00000000-0000-0000-0000-00000000fb01"; + +fn save_sha() -> String { + "ab".repeat(32) +} + +#[tokio::test] +async fn save_core_writes_the_served_bytes_to_the_chosen_file() { + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/pdf\r\n", + "%PDF-1.7 saved", + )]) + .await; + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("saved.pdf"); + let pick_dest = dest.clone(); + let saved = attachment::save_feedback_attachment( + &format!("http://127.0.0.1:{}", addr.port()), + SAVE_FEEDBACK_ID, + &save_sha(), + "application/pdf", + 14, + &nostr::Keys::generate(), + |_, _, _| async move { Ok(Some(pick_dest)) }, + ) + .await; + assert_eq!(saved, Ok(true)); + assert_eq!(std::fs::read(&dest).unwrap(), b"%PDF-1.7 saved"); +} + +#[tokio::test] +async fn save_core_fetch_failure_never_prompts() { + let addr = serve_sequence(vec![("500 Internal Server Error", "", "")]).await; + let saved = attachment::save_feedback_attachment( + &format!("http://127.0.0.1:{}", addr.port()), + SAVE_FEEDBACK_ID, + &save_sha(), + "application/pdf", + 14, + &nostr::Keys::generate(), + |_, _, _| -> std::future::Ready, String>> { + panic!("must not prompt after a failed fetch") + }, + ) + .await; + assert_eq!(saved, Err("admin_attachment_relay_error_500".to_string())); +} + +/// The relay serves non-raster attachments as `application/octet-stream` + +/// `Content-Disposition: attachment` (`media.rs` response policy); the imeta +/// sidecar still says `application/pdf`. +const RELAY_OCTET_STREAM_HEADERS: &str = "Content-Type: application/octet-stream\r\nContent-Disposition: attachment\r\nX-Content-Type-Options: nosniff\r\n"; + +#[tokio::test] +async fn save_core_accepts_a_pdf_served_as_relay_octet_stream() { + let addr = serve_sequence(vec![( + "200 OK", + RELAY_OCTET_STREAM_HEADERS, + "%PDF-1.7 saved", + )]) + .await; + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("report.pdf"); + let pick_dest = dest.clone(); + let saved = attachment::save_feedback_attachment( + &format!("http://127.0.0.1:{}", addr.port()), + SAVE_FEEDBACK_ID, + &save_sha(), + "application/pdf", + 14, + &nostr::Keys::generate(), + |_, _, _| async move { Ok(Some(pick_dest)) }, + ) + .await; + assert_eq!(saved, Ok(true)); + assert_eq!(std::fs::read(&dest).unwrap(), b"%PDF-1.7 saved"); +} + +#[tokio::test] +async fn preview_still_rejects_relay_octet_stream() { + let addr = serve_sequence(vec![( + "200 OK", + RELAY_OCTET_STREAM_HEADERS, + "%PDF-1.7 saved", + )]) + .await; + let fetched = attachment::fetch_feedback_attachment( + &format!("http://127.0.0.1:{}", addr.port()), + SAVE_FEEDBACK_ID, + &save_sha(), + "application/pdf", + 14, + &nostr::Keys::generate(), + helpers::AttachmentUse::Preview, + ) + .await; + assert_eq!(fetched, Err("admin_attachment_mime_mismatch".to_string())); +} + +#[tokio::test] +async fn save_rejects_an_unrelated_content_type() { + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: text/html\r\n", + "%PDF-1.7 saved", + )]) + .await; + let saved = attachment::save_feedback_attachment( + &format!("http://127.0.0.1:{}", addr.port()), + SAVE_FEEDBACK_ID, + &save_sha(), + "application/pdf", + 14, + &nostr::Keys::generate(), + |_, _, _| -> std::future::Ready, String>> { + panic!("must not prompt on a type mismatch") + }, + ) + .await; + assert_eq!(saved, Err("admin_attachment_mime_mismatch".to_string())); +} + +// ── Restrictions community scoping ──────────────────────────────────────── + +#[test] +fn restrictions_url_names_the_active_relay_authority() { + let state = relay_state("wss://Community.Example.com:8443/ws"); + let pubkey = routes::HexPubkey::parse(&"ab".repeat(32)).unwrap(); + let url = restrictions_url( + "https://admin.example.com", + &routes::AdminRoute::MemberBanDelete { pubkey }, + None, + "wss://Community.Example.com:8443/ws", + &state, + ) + .unwrap(); + assert!( + url.ends_with("?communityHost=community.example.com%3A8443"), + "{url}" + ); + assert!(!url.contains("communityId"), "{url}"); +} + +#[test] +fn restrictions_url_carries_the_cursor_and_default_port_host() { + let state = relay_state("wss://relay.example.com"); + let url = restrictions_url( + "https://admin.example.com", + &routes::AdminRoute::MemberRestrictionsList, + Some("tok".to_string()), + "wss://relay.example.com", + &state, + ) + .unwrap(); + assert!( + url.ends_with("/members/restrictions?cursor=tok&communityHost=relay.example.com") + || url.ends_with("/members/restrictions?communityHost=relay.example.com&cursor=tok"), + "{url}" + ); +} + +#[test] +fn restrictions_url_errors_when_the_relay_host_is_unresolvable() { + let state = relay_state("not a url"); + let err = restrictions_url( + "https://admin.example.com", + &routes::AdminRoute::MemberRestrictionsList, + None, + "not a url", + &state, + ) + .unwrap_err(); + assert_eq!(err, "admin_community_host_unresolved"); +} + +#[test] +fn restrictions_url_rejects_a_caller_relay_that_no_longer_matches() { + // The list loaded from relay A; the native relay has since switched to B. + // Every restriction route must fail before building a request URL. + let state = relay_state("wss://relay-b.example.com"); + let pubkey = routes::HexPubkey::parse(&"ab".repeat(32)).unwrap(); + for route in [ + routes::AdminRoute::MemberRestrictionsList, + routes::AdminRoute::MemberBanDelete { + pubkey: pubkey.clone(), + }, + routes::AdminRoute::MemberTimeoutDelete { pubkey }, + ] { + for expected in ["wss://relay-a.example.com", "", " "] { + let err = restrictions_url("https://admin.example.com", &route, None, expected, &state) + .unwrap_err(); + assert_eq!(err, RELAY_SCOPE_CHANGED); + } + } +} diff --git a/desktop/src-tauri/src/commands/admin/origin.rs b/desktop/src-tauri/src/commands/admin/origin.rs new file mode 100644 index 0000000000..7552323b2e --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/origin.rs @@ -0,0 +1,345 @@ +//! `AdminOrigin` — a validated canonical admin console URL origin. +//! +//! An `AdminOrigin` holds exactly `scheme://host[:port]` and nothing else. +//! The webview supplies a raw URL string; this type validates and normalises +//! it before any downstream code can use it to construct request URLs. +//! +//! # Accepted inputs +//! - `https://host` → `https://host` +//! - `https://host:8443` → `https://host:8443` +//! - `http://localhost` → `http://localhost` +//! - `http://localhost:3000` → `http://localhost:3000` +//! - `http://127.0.0.1` → `http://127.0.0.1` +//! - `http://[::1]` → `http://[::1]` +//! +//! # Rejected inputs +//! - Any URL with `http://` to a non-loopback host +//! - Any URL with credentials (`user:pass@`) +//! - Any URL with a non-root path (`/admin`, `/api`) +//! - Any URL with a query string (`?foo=bar`) +//! - Any URL with a fragment (`#section`) +//! - Unknown or unsupported schemes (`ftp://`, `ws://`) + +use super::routes::{AdminQuery, AdminRoute}; + +/// A validated canonical admin console origin: `scheme://host[:port]`. +/// +/// Constructed only through `AdminOrigin::parse`; `canonical` is guaranteed to +/// be a valid canonical origin, and `host`/`port` are the components parsed out +/// of it so no consumer has to re-parse (and re-handle parse failure). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminOrigin { + canonical: String, + host: url::Host, + port: u16, +} + +impl AdminOrigin { + /// Parse and validate an operator-supplied URL into a canonical origin. + /// + /// Strips path, query, and fragment. Returns `Err` with a human-readable + /// message for any disallowed form. + pub fn parse(raw: &str) -> Result { + let parsed = + url::Url::parse(raw).map_err(|_| format!("invalid admin console URL: {raw:?}"))?; + + // Reject credentials. + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("admin console URL must not contain credentials".to_string()); + } + + // Reject non-root path, query, and fragment. + let path = parsed.path(); + if path != "/" && !path.is_empty() { + return Err(format!( + "admin console URL must be an origin only (no path); got {path:?}" + )); + } + if parsed.query().is_some() { + return Err("admin console URL must not contain a query string".to_string()); + } + if parsed.fragment().is_some() { + return Err("admin console URL must not contain a fragment".to_string()); + } + + let host = parsed + .host_str() + .ok_or_else(|| "admin console URL has no host".to_string())?; + + let is_loopback = is_loopback_host(host); + + match parsed.scheme() { + "https" => { + // https is allowed for any host, including loopback (dev with TLS). + } + "http" => { + if !is_loopback { + return Err(format!( + "admin console URL must use HTTPS for non-loopback host {host:?}" + )); + } + } + other => { + return Err(format!( + "admin console URL scheme must be https (or http for loopback); got {other:?}" + )); + } + } + + // Build the canonical origin: scheme + "://" + host + optional :port. + let canonical = match parsed.port() { + Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port), + None => format!("{}://{}", parsed.scheme(), host), + }; + + // Store the parsed host/port so consumers never re-parse the canonical + // string. `host()` is `Some` here — a host-less URL already returned Err + // above — and `port_or_known_default()` falls back to the scheme default + // (443 for the only remaining unknown-scheme case, which cannot occur + // since the scheme match above accepts only https/http). + let host = parsed + .host() + .ok_or_else(|| "admin console URL has no host".to_string())? + .to_owned(); + let port = parsed.port_or_known_default().unwrap_or(443); + + Ok(AdminOrigin { + canonical, + host, + port, + }) + } + + /// The canonical origin string, e.g. `https://admin.example.com`. + pub fn as_str(&self) -> &str { + &self.canonical + } + + /// The parsed host and effective port, used to SSRF-resolve an untrusted + /// relay-advertised origin before it is offered to the operator. Stored at + /// parse time, so this never re-parses or panics. + pub fn resolution_target(&self) -> (url::Host, u16) { + (self.host.clone(), self.port) + } + + /// Build the full request URL for `route` with `query`. + pub fn route_url(&self, route: &AdminRoute, query: &AdminQuery) -> String { + let path = route.path(); + let qs = query.to_query_string(); + if qs.is_empty() { + format!("{}/api/admin/v1{path}", self.canonical) + } else { + format!("{}/api/admin/v1{path}?{qs}", self.canonical) + } + } +} + +/// Returns true when `host` is a loopback address (`localhost`, +/// names ending in `.localhost` per RFC 6761, `127.x.x.x`, `[::1]`). +/// This mirrors `media_download.rs`'s localhost carve-out. +fn is_loopback_host(host: &str) -> bool { + host == "localhost" + || host.ends_with(".localhost") + || host == "[::1]" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Valid inputs ────────────────────────────────────────────────────────── + + #[test] + fn https_host_accepted() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com"); + } + + #[test] + fn https_host_port_accepted() { + let o = AdminOrigin::parse("https://admin.example.com:8443").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com:8443"); + } + + #[test] + fn https_trailing_slash_stripped() { + // url::Url always parses "/" as the path for scheme+host-only URLs. + let o = AdminOrigin::parse("https://admin.example.com/").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com"); + } + + #[test] + fn http_localhost_accepted() { + let o = AdminOrigin::parse("http://localhost").unwrap(); + assert_eq!(o.as_str(), "http://localhost"); + } + + #[test] + fn http_localhost_port_accepted() { + let o = AdminOrigin::parse("http://localhost:3000").unwrap(); + assert_eq!(o.as_str(), "http://localhost:3000"); + } + + #[test] + fn http_dot_localhost_accepted() { + let o = AdminOrigin::parse("http://admin.localhost:3000").unwrap(); + assert_eq!(o.as_str(), "http://admin.localhost:3000"); + } + + #[test] + fn http_dot_localhost_no_port_accepted() { + let o = AdminOrigin::parse("http://admin.localhost").unwrap(); + assert_eq!(o.as_str(), "http://admin.localhost"); + } + + #[test] + fn http_dot_localhost_lookalike_rejected() { + // `admin.localhost.evil` must not be accepted — it doesn't end in `.localhost`. + assert!(AdminOrigin::parse("http://admin.localhost.evil").is_err()); + } + + #[test] + fn http_notlocalhost_rejected() { + // `notlocalhost` is not loopback. + assert!(AdminOrigin::parse("http://notlocalhost").is_err()); + } + + #[test] + fn http_127_accepted() { + let o = AdminOrigin::parse("http://127.0.0.1").unwrap(); + assert_eq!(o.as_str(), "http://127.0.0.1"); + } + + #[test] + fn http_ipv6_loopback_accepted() { + let o = AdminOrigin::parse("http://[::1]:3000").unwrap(); + assert_eq!(o.as_str(), "http://[::1]:3000"); + } + + // ── Invalid inputs ──────────────────────────────────────────────────────── + + #[test] + fn http_non_loopback_rejected() { + assert!(AdminOrigin::parse("http://admin.example.com").is_err()); + } + + #[test] + fn ftp_scheme_rejected() { + assert!(AdminOrigin::parse("ftp://admin.example.com").is_err()); + } + + #[test] + fn credentials_rejected() { + assert!(AdminOrigin::parse("https://user:pass@admin.example.com").is_err()); + } + + #[test] + fn path_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com/api").is_err()); + } + + #[test] + fn query_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com?foo=bar").is_err()); + } + + #[test] + fn fragment_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com#section").is_err()); + } + + #[test] + fn garbage_rejected() { + assert!(AdminOrigin::parse("not a url").is_err()); + } + + // ── route_url builds correct URLs ───────────────────────────────────────── + + #[test] + fn route_url_reports_list_no_query() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportsList, &AdminQuery::default()); + assert_eq!(url, "https://admin.example.com/api/admin/v1/reports"); + } + + #[test] + fn route_url_report_detail() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportDetail { id }, &AdminQuery::default()); + assert_eq!( + url, + "https://admin.example.com/api/admin/v1/reports/00000000-0000-0000-0000-000000000001" + ); + } + + #[test] + fn route_url_feedback_attachment() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(); + let sha256 = + crate::commands::admin::routes::AttachmentHash::parse(&"ab".repeat(32)).unwrap(); + let url = o.route_url( + &AdminRoute::FeedbackAttachment { id, sha256 }, + &AdminQuery::default(), + ); + assert!(url.contains("/api/admin/v1/feedback/")); + assert!(url.contains("/attachments/")); + } + + // ── resolution_target returns the stored components ─────────────────────── + + #[test] + fn resolution_target_returns_stored_host_and_port() { + let https = AdminOrigin::parse("https://admin.example.com").unwrap(); + assert_eq!( + https.resolution_target(), + (url::Host::Domain("admin.example.com".to_string()), 443) + ); + + let localhost = AdminOrigin::parse("http://localhost:3000").unwrap(); + assert_eq!( + localhost.resolution_target(), + (url::Host::Domain("localhost".to_string()), 3000) + ); + + let ipv6 = AdminOrigin::parse("http://[::1]:3000").unwrap(); + assert_eq!( + ipv6.resolution_target(), + (url::Host::Ipv6(std::net::Ipv6Addr::LOCALHOST), 3000) + ); + } + + // ── Host case pin test ──────────────────────────────────────────────────── + // + // The `url` crate (per the URL Standard) lowercases ASCII hostnames during + // parsing. `AdminOrigin` preserves whatever the URL Standard produces — + // which for ASCII hostnames is always lowercase. Host case is not a footgun: + // the relay lowercases `BUZZ_ADMIN_HOST` when it loads config AND compares + // inbound Host/Origin hosts case-insensitively, so a mixed-case + // `BUZZ_ADMIN_HOST` and a mixed-case desktop URL both resolve correctly. + // This test pins the desktop-side canonicalization regardless. + #[test] + fn host_case_preserved_as_supplied() { + // Lowercase input stays lowercase. + let lower = AdminOrigin::parse("https://admin.example.com").unwrap(); + assert_eq!(lower.as_str(), "https://admin.example.com"); + + // The URL Standard normalises ASCII hostnames to lowercase — so "Admin.Example.Com" + // becomes "admin.example.com" after parsing. Both inputs produce the same + // canonical origin, and the relay compares hosts case-insensitively anyway. + let from_mixed = AdminOrigin::parse("https://Admin.Example.Com").unwrap(); + assert_eq!( + from_mixed.as_str(), + "https://admin.example.com", + "url::Url lowercases ASCII hostnames; canonical origin is always lowercase" + ); + + // Consequently the two parsed origins ARE equal — they produce identical + // NIP-98 u-tag values and both match the relay's case-insensitive host check. + assert_eq!(lower.as_str(), from_mixed.as_str()); + } +} diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs new file mode 100644 index 0000000000..e61a827ebf --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -0,0 +1,446 @@ +//! Closed route enum and typed query parameters for the admin API. +//! +//! No IPC surface accepts an arbitrary path; every URL is constructed here +//! from a typed route and typed query parameters. IDs are carried as `Uuid` +//! values so path injection is structurally impossible; the attachment hash is +//! validated to match the relay's exact lowercase-hex-only grammar before a +//! route is constructed. + +/// A validated lowercase 64-hex SHA-256 hash suitable for use as an attachment +/// path segment. Constructed only through [`AttachmentHash::parse`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttachmentHash(String); + +impl AttachmentHash { + /// Parse `raw` as a lowercase 64-hex SHA-256. Returns `Err` for any input + /// that isn't exactly 64 lowercase hex digits, including uppercase A-F (the + /// relay stores lowercase and returns 404 on uppercase). + pub fn parse(raw: &str) -> Result { + if raw.len() != 64 { + return Err(format!( + "attachment hash must be exactly 64 hex characters; got {} characters", + raw.len() + )); + } + if !raw.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + return Err("attachment hash must be lowercase hex only (0-9, a-f); \ + uppercase is rejected — the relay stores lowercase and returns 404 otherwise" + .to_string()); + } + Ok(AttachmentHash(raw.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// The routes exposed by `/api/admin/v1`. +/// +/// IDs are typed `Uuid` — path injection via slash, `..`, `?`, `#`, or +/// percent-escapes is structurally impossible. The attachment hash is an +/// `AttachmentHash`, enforcing exact lowercase-hex grammar. Operator pubkeys +/// are validated hex strings. +#[derive(Debug)] +pub enum AdminRoute { + /// Auth-mode/role/capability discovery. Requires no DB and returns role + /// `null` in token/disabled modes. + Probe, + ReportsList, + ReportDetail { + id: uuid::Uuid, + }, + ReportResolve { + id: uuid::Uuid, + }, + ReportReopen { + id: uuid::Uuid, + }, + ReportCancel { + id: uuid::Uuid, + }, + FeedbackList, + FeedbackDetail { + id: uuid::Uuid, + }, + FeedbackAttachment { + id: uuid::Uuid, + sha256: AttachmentHash, + }, + FeedbackPatch { + id: uuid::Uuid, + }, + OperatorsList, + OperatorPut { + pubkey: HexPubkey, + }, + OperatorDelete { + pubkey: HexPubkey, + }, + /// GET /members/restrictions — communityHost in query. + MemberRestrictionsList, + /// DELETE /members/{pubkey}/ban — communityHost in query. + MemberBanDelete { + pubkey: HexPubkey, + }, + /// DELETE /members/{pubkey}/timeout — communityHost in query. + MemberTimeoutDelete { + pubkey: HexPubkey, + }, +} + +/// A validated 64 lowercase-hex character pubkey for use as a URL path segment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HexPubkey(String); + +impl HexPubkey { + /// Parse `raw` as a 64-character lowercase hex pubkey. + pub fn parse(raw: &str) -> Result { + if raw.len() != 64 { + return Err(format!( + "pubkey must be exactly 64 hex characters; got {}", + raw.len() + )); + } + if !raw.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + return Err("pubkey must be lowercase hex only (0-9, a-f)".to_string()); + } + Ok(HexPubkey(raw.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AdminRoute { + /// Return the URL path component (not including the `/api/admin/v1` prefix). + pub fn path(&self) -> String { + match self { + AdminRoute::Probe => "/probe".to_string(), + AdminRoute::ReportsList => "/reports".to_string(), + AdminRoute::ReportDetail { id } => format!("/reports/{id}"), + AdminRoute::ReportResolve { id } => format!("/reports/{id}/resolve"), + AdminRoute::ReportReopen { id } => format!("/reports/{id}/reopen"), + AdminRoute::ReportCancel { id } => format!("/reports/{id}/cancel"), + AdminRoute::FeedbackList => "/feedback".to_string(), + AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"), + AdminRoute::FeedbackAttachment { id, sha256 } => { + format!("/feedback/{id}/attachments/{}", sha256.as_str()) + } + AdminRoute::FeedbackPatch { id } => format!("/feedback/{id}"), + AdminRoute::OperatorsList => "/operators".to_string(), + AdminRoute::OperatorPut { pubkey } => format!("/operators/{}", pubkey.as_str()), + AdminRoute::OperatorDelete { pubkey } => format!("/operators/{}", pubkey.as_str()), + AdminRoute::MemberRestrictionsList => "/members/restrictions".to_string(), + AdminRoute::MemberBanDelete { pubkey } => { + format!("/members/{}/ban", pubkey.as_str()) + } + AdminRoute::MemberTimeoutDelete { pubkey } => { + format!("/members/{}/timeout", pubkey.as_str()) + } + } + } +} + +/// Optional query parameters for the reports-list endpoint. +/// +/// All fields are `Option` so the struct can be constructed with only +/// the fields the caller cares about; `to_query_string` omits `None` fields. +#[derive(Debug, Default)] +pub struct AdminQuery { + pub community_id: Option, + pub status: Option, + pub report_type: Option, + pub target_kind: Option, + pub after: Option, + pub before: Option, + pub limit: Option, + /// Visibility scope for the reports list. + /// `Some("all")` requests every status; `None` omits the parameter and + /// uses the relay's escalated-only default. + pub scope: Option, + /// Opaque continuation token from a prior page's `nextCursor`. + pub cursor: Option, + /// Community host the relay resolves to its tenant (restrictions routes). + pub community_host: Option, +} + +impl AdminQuery { + /// Serialise to a URL query string (no leading `?`). Returns an empty + /// string when all fields are `None`. + pub fn to_query_string(&self) -> String { + let mut parts: Vec = Vec::new(); + if let Some(v) = &self.community_id { + parts.push(format!("communityId={}", urlencoded(v))); + } + if let Some(v) = &self.community_host { + parts.push(format!("communityHost={}", urlencoded(v))); + } + if let Some(v) = &self.status { + parts.push(format!("status={}", urlencoded(v))); + } + if let Some(v) = &self.report_type { + parts.push(format!("reportType={}", urlencoded(v))); + } + if let Some(v) = &self.target_kind { + parts.push(format!("targetKind={}", urlencoded(v))); + } + if let Some(v) = &self.after { + parts.push(format!("after={}", urlencoded(v))); + } + if let Some(v) = &self.before { + parts.push(format!("before={}", urlencoded(v))); + } + if let Some(v) = &self.limit { + parts.push(format!("limit={v}")); + } + if let Some(v) = &self.scope { + parts.push(format!("scope={}", urlencoded(v))); + } + if let Some(v) = &self.cursor { + parts.push(format!("cursor={}", urlencoded(v))); + } + parts.join("&") + } +} + +/// Percent-encode a query parameter value, matching `url::form_urlencoded`. +fn urlencoded(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── AttachmentHash validation ───────────────────────────────────────────── + + #[test] + fn attachment_hash_valid_lowercase_hex() { + let h = AttachmentHash::parse(&"a".repeat(64)).unwrap(); + assert_eq!(h.as_str(), "a".repeat(64)); + } + + #[test] + fn attachment_hash_rejects_too_short() { + assert!(AttachmentHash::parse(&"a".repeat(63)).is_err()); + } + + #[test] + fn attachment_hash_rejects_too_long() { + assert!(AttachmentHash::parse(&"a".repeat(65)).is_err()); + } + + #[test] + fn attachment_hash_rejects_uppercase() { + // Uppercase passes is_ascii_hexdigit() but the relay returns 404 for it. + // AttachmentHash::parse must reject uppercase. + assert!(AttachmentHash::parse(&"A".repeat(64)).is_err()); + let mixed = format!("{}A{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&mixed).is_err()); + } + + #[test] + fn attachment_hash_rejects_non_hex_chars() { + // 'g' is not a hex digit. + assert!(AttachmentHash::parse(&"g".repeat(64)).is_err()); + } + + #[test] + fn attachment_hash_rejects_slash() { + let s = format!("{}/{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_dot_dot() { + let s = format!("{}..{}", "a".repeat(31), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_percent_escape() { + // URL-encoded slash would be %2F — 3 chars, must fail length check too. + assert!(AttachmentHash::parse("%2F").is_err()); + // But also reject any % in a 64-char input. + let s = format!("{}%2{}", "a".repeat(31), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_query_fragment() { + let s = format!("{}?{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + let s2 = format!("{}#{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s2).is_err()); + } + + // ── AdminRoute::path ───────────────────────────────────────────────────── + + #[test] + fn reports_list_path() { + assert_eq!(AdminRoute::ReportsList.path(), "/reports"); + } + + #[test] + fn probe_path() { + assert_eq!(AdminRoute::Probe.path(), "/probe"); + } + + #[test] + fn report_detail_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + assert_eq!( + AdminRoute::ReportDetail { id }.path(), + "/reports/00000000-0000-0000-0000-000000000001" + ); + } + + #[test] + fn report_reopen_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(); + assert_eq!( + AdminRoute::ReportReopen { id }.path(), + "/reports/00000000-0000-0000-0000-000000000003/reopen" + ); + } + + #[test] + fn report_cancel_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(); + assert_eq!( + AdminRoute::ReportCancel { id }.path(), + "/reports/00000000-0000-0000-0000-000000000004/cancel" + ); + } + + #[test] + fn feedback_attachment_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); + let hash = AttachmentHash::parse(&"ab".repeat(32)).unwrap(); + let path = AdminRoute::FeedbackAttachment { + id, + sha256: hash.clone(), + } + .path(); + assert_eq!( + path, + format!( + "/feedback/00000000-0000-0000-0000-000000000002/attachments/{}", + hash.as_str() + ) + ); + } + + // ── AdminQuery ─────────────────────────────────────────────────────────── + + #[test] + fn query_empty_produces_no_string() { + assert_eq!(AdminQuery::default().to_query_string(), ""); + } + + #[test] + fn query_limit_only() { + let q = AdminQuery { + limit: Some(50), + ..Default::default() + }; + assert_eq!(q.to_query_string(), "limit=50"); + } + + #[test] + fn query_multiple_params() { + let q = AdminQuery { + status: Some("open".to_string()), + limit: Some(100), + ..Default::default() + }; + let qs = q.to_query_string(); + assert!(qs.contains("status=open"), "expected status in {qs}"); + assert!(qs.contains("limit=100"), "expected limit in {qs}"); + } + + #[test] + fn query_value_is_percent_encoded() { + let q = AdminQuery { + status: Some("open&active".to_string()), + ..Default::default() + }; + let qs = q.to_query_string(); + // & in value must be encoded so it doesn't split the query. + assert!(!qs.contains("status=open&active"), "bare & leaked: {qs}"); + assert!(qs.contains("status="), "status key missing: {qs}"); + } + + #[test] + fn query_scope_all_serializes() { + let q = AdminQuery { + scope: Some("all".to_string()), + ..Default::default() + }; + let qs = q.to_query_string(); + assert_eq!(qs, "scope=all", "scope=all must serialize; got: {qs}"); + } + + #[test] + fn query_cursor_is_percent_encoded() { + let q = AdminQuery { + community_id: Some("c".to_string()), + cursor: Some("a+b/=".to_string()), + ..Default::default() + }; + assert_eq!(q.to_query_string(), "communityId=c&cursor=a%2Bb%2F%3D"); + } + + #[test] + fn query_scope_none_omitted() { + let q = AdminQuery { + limit: Some(10), + ..Default::default() + }; + let qs = q.to_query_string(); + assert!( + !qs.contains("scope"), + "scope must be absent when None; got: {qs}" + ); + } + + #[test] + fn query_scope_all_with_limit() { + let q = AdminQuery { + scope: Some("all".to_string()), + limit: Some(50), + ..Default::default() + }; + let qs = q.to_query_string(); + assert!(qs.contains("scope=all"), "scope=all must appear; got: {qs}"); + assert!(qs.contains("limit=50"), "limit=50 must appear; got: {qs}"); + } + + #[test] + fn member_restrictions_list_path() { + assert_eq!( + AdminRoute::MemberRestrictionsList.path(), + "/members/restrictions" + ); + } + + #[test] + fn member_ban_delete_path() { + let pubkey = HexPubkey::parse(&"ab".repeat(32)).unwrap(); + assert_eq!( + AdminRoute::MemberBanDelete { pubkey }.path(), + format!("/members/{}/ban", "ab".repeat(32)) + ); + } + + #[test] + fn member_timeout_delete_path() { + let pubkey = HexPubkey::parse(&"cd".repeat(32)).unwrap(); + assert_eq!( + AdminRoute::MemberTimeoutDelete { pubkey }.path(), + format!("/members/{}/timeout", "cd".repeat(32)) + ); + } +} diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 5aab4ce534..3e3a7e0903 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -138,6 +138,7 @@ fn persona_with_model(model: &str) -> AgentDefinition { display_name: "Persona".to_string(), avatar_url: None, system_prompt: "You are a persona.".to_string(), + acp_command: None, runtime: None, model: Some(model.to_string()), provider: None, diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 7c382a663b..902ae0b49e 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -1,5 +1,4 @@ use super::*; - #[test] fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() { use crate::managed_agents::RespondTo; diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs index 178ec0bb6d..0eaca0192c 100644 --- a/desktop/src-tauri/src/commands/agent_providers.rs +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -1,4 +1,22 @@ -use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo}; +use crate::managed_agents::{ + discover_acp_command_candidates, discover_provider_candidates, invoke_provider, + AcpCommandCandidate, BackendProviderInfo, +}; + +#[tauri::command] +pub async fn discover_acp_commands() -> Result, String> { + tokio::task::spawn_blocking(|| { + discover_acp_command_candidates() + .into_iter() + .map(|(command, path)| AcpCommandCandidate { + command, + binary_path: path.display().to_string(), + }) + .collect() + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} #[tauri::command] pub async fn discover_backend_providers() -> Result, String> { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 00a968f20f..c610d8dad7 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,8 +1,7 @@ +use super::managed_agent_definition::validate_create_definition; use nostr::{Keys, ToBech32}; use tauri::{AppHandle, State}; -use super::managed_agent_definition::validate_create_definition; - use crate::{ app_state::AppState, managed_agents::{ @@ -609,9 +608,10 @@ pub async fn create_managed_agent( auth_tag: auth_tag.clone(), relay_url: resolved_relay_url.clone(), avatar_url: resolved_avatar_url.clone(), - acp_command: input - .acp_command - .as_deref() + acp_command: linked_persona + .as_ref() + .and_then(|persona| persona.acp_command.as_deref()) + .or(input.acp_command.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(DEFAULT_ACP_COMMAND) diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f534b8584f..31b5da67eb 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -78,6 +78,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen display_name: "Test Persona".to_string(), avatar_url: None, system_prompt: "".to_string(), + acp_command: None, runtime: None, model: model.map(str::to_string), provider: provider.map(str::to_string), diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 8852fcb7e0..ad44f86f9e 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -137,31 +137,55 @@ pub async fn sign_event( content: String, created_at: Option, tags: Vec>, + allow_self_tagging: Option, state: State<'_, AppState>, ) -> Result { let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { - let nostr_tags = tags - .into_iter() - .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) - .collect::, _>>()?; - - let mut builder = EventBuilder::new(Kind::Custom(kind), content).tags(nostr_tags); - if let Some(created_at) = created_at { - builder = builder.custom_created_at(Timestamp::from(created_at)); - } - - let event = builder - .sign_with_keys(&keys) - .map_err(|error| format!("sign failed: {error}"))?; - - Ok(event.as_json()) + sign_event_json( + &keys, + kind, + content, + created_at, + tags, + allow_self_tagging == Some(true), + ) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Build and sign the event behind [`sign_event`]. nostr strips a `p` tag that +/// names the author unless `allow_self_tagging` is set, so only callers that +/// need a self-tag (self-reports) opt in. +fn sign_event_json( + keys: &Keys, + kind: u16, + content: String, + created_at: Option, + tags: Vec>, + allow_self_tagging: bool, +) -> Result { + let nostr_tags = tags + .into_iter() + .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) + .collect::, _>>()?; + + let mut builder = EventBuilder::new(Kind::Custom(kind), content).tags(nostr_tags); + if let Some(created_at) = created_at { + builder = builder.custom_created_at(Timestamp::from(created_at)); + } + if allow_self_tagging { + builder = builder.allow_self_tagging(); + } + + builder + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign failed: {error}")) +} + #[tauri::command] pub async fn decrypt_observer_event( event_json: String, @@ -815,3 +839,72 @@ mod nostr_identity_binding_tests { #[cfg(test)] #[path = "identity_key_backup_tests.rs"] mod identity_key_backup_tests; + +#[cfg(test)] +mod sign_event_self_tagging_tests { + use super::sign_event_json; + use nostr::{Event, JsonUtil, Keys}; + + fn build_event(keys: &Keys, kind: u16, p: &str, allow_self_tagging: bool) -> Event { + let json = sign_event_json( + keys, + kind, + String::new(), + None, + vec![vec!["p".to_owned(), p.to_owned()]], + allow_self_tagging, + ) + .expect("sign must succeed"); + Event::from_json(json).expect("valid event json") + } + + fn p_tag_values(event: &nostr::Event) -> Vec { + event + .tags + .iter() + .filter(|t| t.kind() == nostr::TagKind::p()) + .filter_map(|t| t.content().map(str::to_owned)) + .collect() + } + + #[test] + fn self_p_tag_is_stripped_without_flag() { + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let event = build_event(&keys, 1984, &pubkey_hex, false); + assert!( + p_tag_values(&event).is_empty(), + "self p-tag must be stripped when allow_self_tagging is false" + ); + } + + #[test] + fn self_p_tag_survives_with_flag() { + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let event = build_event(&keys, 1984, &pubkey_hex, true); + assert_eq!( + p_tag_values(&event), + vec![pubkey_hex], + "self p-tag must be preserved when allow_self_tagging is true" + ); + } + + #[test] + fn third_party_p_tag_always_survives() { + // A p-tag that does NOT match the signing key must always survive + // regardless of the flag — verify both branches. + let keys = Keys::generate(); + let other_keys = Keys::generate(); + let other_hex = other_keys.public_key().to_hex(); + + for flag in [false, true] { + let event = build_event(&keys, 1984, &other_hex, flag); + assert_eq!( + p_tag_values(&event), + vec![other_hex.clone()], + "third-party p-tag must survive with allow_self_tagging={flag}" + ); + } + } +} diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index e841d4b2f5..47d76520b5 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -610,6 +610,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: "test".to_string(), source_is_builtin: false, system_prompt: None, @@ -661,6 +662,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: "test".to_string(), source_is_builtin: false, system_prompt: None, @@ -708,6 +710,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: "test".to_string(), source_is_builtin: false, system_prompt: None, diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index e108e7c88e..36ad0e47c6 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -158,6 +158,7 @@ mod tests { version: 1, definition: AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: "Tree Trunks".to_string(), source_is_builtin: false, system_prompt: Some("You are a helpful agent.".to_string()), diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c8184a0103..01b1ddc882 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; mod agent_access; mod agent_auth; mod agent_config; @@ -75,6 +76,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use admin::*; pub use agent_access::*; pub use agent_auth::*; pub use agent_config::*; diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index a0a3e63f50..7d198f86ef 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -31,6 +31,7 @@ pub async fn create_persona( validate_agent_definition_text(&display_name, &system_prompt)?; let description = normalize_description(input.description)?; let avatar_url = trim_optional(input.avatar_url); + let acp_command = trim_optional(input.acp_command); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); let provider = trim_optional(input.provider); @@ -61,6 +62,7 @@ pub async fn create_persona( avatar_url, description, system_prompt, + acp_command, runtime, model, provider, diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index e961d47c58..20f46e640e 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -674,9 +674,11 @@ fn event_d_tag(event: &nostr::Event) -> Result { /// in place, or push it when none matches. /// /// The match key is `persona_d_tag` — the same derivation the outbound path -/// uses — so the inbound and outbound keys can never drift. On match, only the -/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and -/// `created_at` survive. On no match, the parsed record is inserted as-is; since +/// uses — so the inbound and outbound keys can never drift. On match, every +/// field projected by `PersonaEventContent` is overwritten (except a redacted +/// shared transport preserves a nonportable local override), while local `id`, +/// `env_vars`, `source_team`, and `created_at` survive. On no match, the parsed +/// record is inserted as-is; since /// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as /// the id and a re-received event stays idempotent (no duplicate row). fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { @@ -690,6 +692,17 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefi local.avatar_url = inbound.avatar_url; local.description = inbound.description; local.system_prompt = inbound.system_prompt; + // Shared absence can be a redacted machine-local transport. Keep + // only that local override; portable/legacy absence still resets. + // Shared writers emit explicit buzz-acp for an intentional reset. + let redacted_local_command = inbound.shared + && inbound.acp_command.is_none() + && local.acp_command.as_deref().is_some_and(|command| { + !crate::managed_agents::is_portable_acp_command(command) + }); + if !redacted_local_command { + local.acp_command = inbound.acp_command; + } local.runtime = inbound.runtime; local.model = inbound.model; local.provider = inbound.provider; diff --git a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs index 5d192780ae..5cf2450ecb 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs @@ -32,6 +32,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index eb59ca0b1c..d60a84a650 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -16,6 +16,7 @@ fn local_in_app() -> AgentDefinition { display_name: "Local".to_string(), avatar_url: None, system_prompt: "local prompt".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: Some("opus".to_string()), provider: Some("anthropic".to_string()), @@ -46,6 +47,7 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { display_name: display_name.to_string(), avatar_url: Some("https://example.com/a.png".to_string()), system_prompt: "remote prompt".to_string(), + acp_command: None, runtime: Some("acp".to_string()), model: Some("sonnet".to_string()), provider: Some("openai".to_string()), @@ -69,13 +71,16 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { #[test] fn in_app_persona_matches_existing_uuid_and_patches() { let mut personas = vec![local_in_app()]; - apply_inbound_persona(&mut personas, inbound_for(UUID, "Remote")); + let mut inbound = inbound_for(UUID, "Remote"); + inbound.acp_command = Some("buzz-janet-acp".to_string()); + apply_inbound_persona(&mut personas, inbound); assert_eq!(personas.len(), 1, "no duplicate row"); let p = &personas[0]; // Projected fields patched. assert_eq!(p.display_name, "Remote"); assert_eq!(p.system_prompt, "remote prompt"); + assert_eq!(p.acp_command.as_deref(), Some("buzz-janet-acp")); assert_eq!(p.provider, Some("openai".to_string())); // Local identity + secrets + lineage preserved. assert_eq!(p.id, UUID); @@ -933,3 +938,56 @@ fn inbound_definition_less_agent_accepts_visible_multiline_prompt() { assert!(validate_inbound_managed_agent_definition(&inbound).is_ok()); } + +#[test] +fn shared_transport_redaction_preserves_local_override_but_explicit_stock_resets() { + use crate::managed_agents::persona_events::{build_persona_event, persona_from_event}; + let keys = nostr::Keys::generate(); + let mut local = local_in_app(); + local.acp_command = Some("/opt/custom-acp".into()); + let mut published = local.clone(); + published.shared = true; + let event = build_persona_event(&published) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert!(!event.content.contains("/opt/custom-acp")); + let mut personas = vec![local]; + for _ in 0..2 { + apply_inbound_persona(&mut personas, persona_from_event(&event).unwrap()); + assert_eq!(personas[0].acp_command.as_deref(), Some("/opt/custom-acp")); + } + let mut fresh_device = Vec::new(); + apply_inbound_persona(&mut fresh_device, persona_from_event(&event).unwrap()); + assert_eq!(fresh_device[0].acp_command, None); + // Redaction must not preserve a stale portable wrapper. + personas[0].acp_command = Some("buzz-old-acp".into()); + apply_inbound_persona(&mut personas, persona_from_event(&event).unwrap()); + assert_eq!(personas[0].acp_command, None); + // Stock is normalized to None by the store, but shared publication makes + // the reset explicit so it also replaces an owner's legacy local command. + personas[0].acp_command = Some("/opt/custom-acp".into()); + published.acp_command = None; + let reset = build_persona_event(&published) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + apply_inbound_persona(&mut personas, persona_from_event(&reset).unwrap()); + assert_eq!(personas[0].acp_command.as_deref(), Some("buzz-acp")); + // Non-catalog owner-sync keeps both legacy custom values and clears. + published.shared = false; + published.acp_command = Some("/opt/other-acp".into()); + let custom = build_persona_event(&published) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + apply_inbound_persona(&mut personas, persona_from_event(&custom).unwrap()); + assert_eq!(personas[0].acp_command.as_deref(), Some("/opt/other-acp")); + published.acp_command = None; + let clear = build_persona_event(&published) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + apply_inbound_persona(&mut personas, persona_from_event(&clear).unwrap()); + assert_eq!(personas[0].acp_command, None); +} diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 02f49e631e..47836cd30c 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -316,6 +316,7 @@ mod tests { display_name: "Catalog Reviewer".to_string(), avatar_url: None, system_prompt: "Review the catalog.".to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index 1b626fb906..9df8251761 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -152,6 +152,7 @@ mod tests { display_name: "Catalog Reviewer".to_string(), avatar_url: None, system_prompt: "Review the catalog.".to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index 04b5b9255d..f1e4b15dec 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -499,6 +499,7 @@ mod png_body_tests { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: "Agent".to_string(), source_is_builtin: false, system_prompt: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 0163def7f9..1eec21d9fa 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -82,6 +82,7 @@ fn make_snapshot( version: FORMAT_VERSION, definition: AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: "Test Agent".to_string(), source_is_builtin: false, system_prompt: Some("You are helpful.".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index f5e18e722b..0c9f170916 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -570,6 +570,7 @@ pub async fn confirm_agent_snapshot_import( .system_prompt .clone() .unwrap_or_default(), + acp_command: snapshot.definition.acp_command.clone(), runtime: snapshot.definition.runtime.clone(), model: snapshot.definition.model.clone(), provider: snapshot.definition.provider.clone(), @@ -611,9 +612,13 @@ pub async fn confirm_agent_snapshot_import( auth_tag: auth_tag.clone(), relay_url: String::new(), // resolves to workspace relay at runtime avatar_url: effective_avatar.clone(), - // Machine-local commands: derive from the runtime catalog at - // spawn time — never manufacture from snapshot data. - acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), + // Only the validated portable ACP alias crosses the snapshot boundary. + // Harness paths still resolve locally at spawn. + acp_command: snapshot + .definition + .acp_command + .clone() + .unwrap_or_else(|| crate::managed_agents::DEFAULT_ACP_COMMAND.to_string()), agent_command: String::new(), agent_command_override: None, agent_args: vec![], diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index fab0e1b1fb..5a41b86f8d 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -114,6 +114,7 @@ fn make_snapshot( version: FORMAT_VERSION, definition: AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: "Test Agent".to_string(), source_is_builtin: false, system_prompt: Some("You are helpful.".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index 46d0c8a99d..76939f75d0 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -162,6 +162,7 @@ pub(super) async fn update_persona_with( validate_agent_definition_text(&display_name, &system_prompt)?; let description = normalize_description(input.description)?; let avatar_url = trim_optional(input.avatar_url); + let acp_command = trim_optional(input.acp_command); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); let provider = trim_optional(input.provider); @@ -193,6 +194,7 @@ pub(super) async fn update_persona_with( persona.avatar_url = avatar_url; persona.description = description; persona.system_prompt = system_prompt; + persona.acp_command = acp_command; persona.runtime = runtime; persona.model = model; persona.provider = provider; diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 2473c97ddd..8f43e4f8ce 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -126,6 +126,7 @@ fn definition_from_snapshot( member.profile.about.as_deref(), ), system_prompt: member.definition.system_prompt.clone().unwrap_or_default(), + acp_command: member.definition.acp_command.clone(), runtime: member.definition.runtime.clone(), model: member.definition.model.clone(), provider: member.definition.provider.clone(), diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index a338475a9a..4cf1bf49e4 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -13,6 +13,7 @@ fn member(name: &str) -> AgentSnapshot { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: name.to_string(), source_is_builtin: false, system_prompt: Some(format!("{name} prompt")), @@ -62,6 +63,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { display_name: "Alice".to_string(), avatar_url: None, system_prompt: "Alice prompt".to_string(), + acp_command: Some("buzz-janet-acp".to_string()), runtime: Some("goose".to_string()), model: None, provider: None, @@ -87,6 +89,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { display_name: "Bob".to_string(), avatar_url: None, system_prompt: "Bob prompt".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: None, provider: None, @@ -146,6 +149,12 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { Some("A careful reviewer.") ); assert_eq!(decoded.members[1].profile.about, None); + let imported = build_import_definitions(&decoded, false, "now").unwrap(); + assert_eq!(imported[0].acp_command.as_deref(), Some("buzz-janet-acp")); + assert_eq!( + imported[0].clone().into_agent_record().acp_command, + "buzz-janet-acp" + ); assert!(decoded.members.iter().all(|member| { member.memory.level == MemoryLevel::None && member.memory.entries.is_empty() })); @@ -160,6 +169,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { display_name: "Alice".to_string(), avatar_url: None, system_prompt: "Alice prompt".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: None, provider: None, diff --git a/desktop/src-tauri/src/commands/teams/adopt/apply.rs b/desktop/src-tauri/src/commands/teams/adopt/apply.rs index 06daa3964f..c3f74a6704 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/apply.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/apply.rs @@ -442,6 +442,7 @@ fn member_copy( description: None, avatar_url: member.avatar_url.clone(), system_prompt: member.system_prompt.clone().unwrap_or_default(), + acp_command: member.acp_command.clone(), runtime: member.runtime.clone(), model: member.model.clone(), provider: member.provider.clone(), diff --git a/desktop/src-tauri/src/commands/teams/adopt/tests.rs b/desktop/src-tauri/src/commands/teams/adopt/tests.rs index 705278d3ce..084d9e2546 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/tests.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/tests.rs @@ -11,6 +11,7 @@ use crate::managed_agents::{ }; use nostr::{EventBuilder, JsonUtil, Kind, Tag}; use std::collections::BTreeMap; +mod acp_transport; mod concealment; // executable-text concealment gate (Carl P1) mod retention; // adoption-path retention enqueue (Wes/Carl P1) mod reuse; // built-in reuse decision (`reusable_builtin`) @@ -27,6 +28,7 @@ fn persona(id: &str, prompt: &str) -> AgentDefinition { description: None, avatar_url: None, system_prompt: prompt.to_string(), + acp_command: None, runtime: None, model: None, provider: None, @@ -50,6 +52,7 @@ fn persona(id: &str, prompt: &str) -> AgentDefinition { fn member(member_key: &str, prompt: &str) -> TeamCatalogMember { TeamCatalogMember { session_policy: Default::default(), + acp_command: None, member_key: member_key.to_string(), display_name: member_key.to_string(), system_prompt: Some(prompt.to_string()), diff --git a/desktop/src-tauri/src/commands/teams/adopt/tests/acp_transport.rs b/desktop/src-tauri/src/commands/teams/adopt/tests/acp_transport.rs new file mode 100644 index 0000000000..84ff6c5011 --- /dev/null +++ b/desktop/src-tauri/src/commands/teams/adopt/tests/acp_transport.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn signed_team_transport_survives_adoption_and_next_spawn_snapshot() { + let mut definition = persona("m1", "Do the work."); + definition.acp_command = Some("buzz-janet-acp".to_string()); + let (event, source) = published(&team_fixture(vec!["m1".to_string()]), &[definition], true); + let content = verified_head_content(&event, &source, &event.id.to_hex()).unwrap(); + let (personas, _) = plan_add(&[], &[], &source, &content, NOW) + .unwrap() + .stores + .unwrap(); + assert_eq!(personas[0].acp_command.as_deref(), Some("buzz-janet-acp")); + let mut instance = personas[0].clone().into_agent_record(); + instance.acp_command = "buzz-acp".to_string(); + crate::managed_agents::persona_events::apply_persona_snapshot(&mut instance, &personas[0]); + assert_eq!(instance.acp_command, "buzz-janet-acp"); +} diff --git a/desktop/src-tauri/src/commands/teams/pending/tests.rs b/desktop/src-tauri/src/commands/teams/pending/tests.rs index 35b01c566f..3992ab6cfb 100644 --- a/desktop/src-tauri/src/commands/teams/pending/tests.rs +++ b/desktop/src-tauri/src/commands/teams/pending/tests.rs @@ -18,6 +18,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/commands/teams/sharing/tests.rs b/desktop/src-tauri/src/commands/teams/sharing/tests.rs index e2f149f78f..6d367f3526 100644 --- a/desktop/src-tauri/src/commands/teams/sharing/tests.rs +++ b/desktop/src-tauri/src/commands/teams/sharing/tests.rs @@ -20,6 +20,7 @@ fn member(id: &str) -> AgentDefinition { description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs index 54b856ceca..ba6f4b1fe9 100644 --- a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs +++ b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs @@ -18,6 +18,7 @@ fn member(id: &str, prompt: &str) -> AgentDefinition { description: None, avatar_url: None, system_prompt: prompt.to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f09abbe064..dce4a05a81 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -242,6 +242,10 @@ pub fn run() { macos_notifications::init(&app_handle)?; } + // Initialise the no-redirect admin HTTP client singleton before any + // admin command can be invoked. Must run before setup completes. + commands::admin::client::init_admin_client()?; + // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe // completes atomically on crash recovery. @@ -730,6 +734,7 @@ pub fn run() { mesh_installed_models, mesh_model_catalog, update_managed_agent, + discover_acp_commands, discover_backend_providers, probe_backend_provider, persona_catalog::fetch_persona_catalog, @@ -873,6 +878,27 @@ pub fn run() { tray_menu::take_tray_actions, #[cfg(target_os = "macos")] tray_menu::update_tray_agent_activity, + // ── Desktop admin surface ──────────────────────────────────────── + admin_probe, + admin_list_reports, + admin_get_report, + admin_list_feedback, + admin_get_feedback, + admin_fetch_feedback_attachment, + admin_save_attachment, + admin_resolve_report, + admin_reopen_report, + admin_cancel_report, + admin_patch_feedback, + admin_list_operators, + admin_put_operator, + admin_delete_operator, + admin_list_restrictions, + admin_lift_ban, + admin_lift_timeout, + get_admin_origin, + set_admin_origin, + admin_discover_origin, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 70b685fe36..c103e3b357 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -23,7 +23,7 @@ //! - `auth_tag` (NIP-OA) //! - `env_vars` (API keys / credentials) //! - `relay_url` (machine-local endpoint) -//! - `acp_command` / `agent_command` / `agent_command_override` / `agent_args` +//! - nonportable `acp_command` values / `agent_command` / `agent_command_override` / `agent_args` //! (machine-local harness paths) //! - `mcp_command` (machine-local) //! - runtime state: `runtime_pid`, `backend_agent_id`, `backend` blob, @@ -106,6 +106,9 @@ pub struct AgentSnapshotDefinition { pub system_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime: Option, + /// Portable transport alias; machine-specific legacy commands are excluded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_command: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -212,6 +215,8 @@ pub fn build_snapshot( source_is_builtin: record.is_builtin, system_prompt: record.system_prompt.clone(), runtime: record.runtime.clone(), + acp_command: super::is_portable_acp_command(&record.acp_command) + .then(|| record.acp_command.clone()), model: record.model.clone(), provider: record.provider.clone(), parallelism: record.definition_parallelism.or(Some(record.parallelism)), @@ -408,6 +413,7 @@ pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> snapshot.version )); } + super::validate_portable_acp_command(snapshot.definition.acp_command.as_deref())?; if snapshot.definition.name.trim().is_empty() { return Err("Snapshot definition.name is empty".to_string()); } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8b0ddb3fc0..4574e37f67 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -333,6 +333,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { session_policy: Default::default(), + acp_command: None, name: "Locked Test".to_string(), system_prompt: Some("You are a locked test agent.".to_string()), runtime: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index 3bedddcbe5..bd50a4a6b3 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -670,3 +670,54 @@ fn unsupported_version_is_rejected() { assert!(result.is_err()); assert!(result.unwrap_err().contains("Unsupported snapshot version")); } + +#[test] +fn portable_acp_command_survives_json_and_png_and_legacy_absence() { + let mut record = minimal_record(); + record.acp_command = "buzz-janet-acp".to_string(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let json = encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_json(&json).unwrap(); + assert_eq!( + decoded.definition.acp_command.as_deref(), + Some("buzz-janet-acp") + ); + let png = encode_snapshot_png(&snapshot, None).unwrap(); + assert_eq!( + decode_snapshot_png(&png).unwrap().definition.acp_command, + decoded.definition.acp_command + ); + let mut legacy: serde_json::Value = serde_json::from_slice(&json).unwrap(); + legacy["definition"] + .as_object_mut() + .unwrap() + .remove("acpCommand"); + assert_eq!( + decode_snapshot_json(&serde_json::to_vec(&legacy).unwrap()) + .unwrap() + .definition + .acp_command, + None + ); +} + +#[test] +fn foreign_snapshot_rejects_nonportable_acp_commands() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + for command in [ + "/tmp/buzz-janet-acp", + r"C:\buzz-janet-acp.cmd", + "sh", + "buzz-a b-acp", + ] { + snapshot.definition.acp_command = Some(command.to_string()); + let json = serde_json::to_vec(&snapshot).unwrap(); + assert!(decode_snapshot_json(&json) + .unwrap_err() + .contains("ACP command")); + let png = encode_snapshot_png(&snapshot, None).unwrap(); + assert!(decode_snapshot_png(&png) + .unwrap_err() + .contains("ACP command")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 84dd7e99da..c6c96c7364 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -1,3 +1,4 @@ +use super::discovery::{command_search::command_discovery_dirs, resolve_command}; use sha2::{Digest, Sha256}; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; @@ -583,6 +584,79 @@ fn provider_id_from_filename(name: &str) -> Option<&str> { (!id.is_empty()).then_some(id) } +fn executable_search_dirs() -> Vec { + let path_var = std::env::var_os("PATH").unwrap_or_default(); + let mut dirs: Vec = std::env::split_paths(&path_var).collect(); + + // Prepend the exe parent dir (Contents/MacOS/ in a .app bundle) so bundled + // extensions are found even when the process PATH is minimal. + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + let parent_buf = parent.to_path_buf(); + if !dirs.contains(&parent_buf) { + dirs.insert(0, parent_buf); + } + } + } + + // Also include ~/.local/bin — the conventional location for user-installed + // Buzz extension binaries (symlinks created by install scripts). + if let Some(home) = dirs::home_dir() { + let local_bin = home.join(".local").join("bin"); + if !dirs.contains(&local_bin) { + dirs.push(local_bin); + } + } + + dirs +} + +fn strip_windows_command_extension(name: &str) -> &str { + [".exe", ".bat", ".cmd"] + .into_iter() + .find_map(|extension| { + name.get(name.len().saturating_sub(extension.len())..) + .filter(|suffix| suffix.eq_ignore_ascii_case(extension)) + .map(|_| &name[..name.len() - extension.len()]) + }) + .unwrap_or(name) +} + +/// Whether a transport is a portable stock or conventional wrapper alias. +/// Shared artifacts carry aliases only, never machine paths or command lines. +/// Owner-controlled native commands and owner-device sync retain legacy values. +pub(crate) fn is_portable_acp_command(command: &str) -> bool { + if command == super::DEFAULT_ACP_COMMAND { + return true; + } + command.len() <= 255 + && command + .strip_prefix("buzz-") + .and_then(|name| name.strip_suffix("-acp")) + .is_some_and(|name| { + !name.is_empty() + && name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')) + }) +} + +/// Reject nonportable commands at foreign catalog and snapshot boundaries. +pub(crate) fn validate_portable_acp_command(command: Option<&str>) -> Result<(), String> { + if command.is_some_and(|command| !is_portable_acp_command(command)) { + return Err("ACP command must be buzz-acp or a portable buzz-*-acp alias".to_string()); + } + Ok(()) +} + +fn acp_command_from_filename(name: &str, require_windows_extension: bool) -> Option<&str> { + let command = strip_windows_command_extension(name); + if require_windows_extension && command == name { + return None; + } + (command != super::DEFAULT_ACP_COMMAND && is_portable_acp_command(command)).then_some(command) +} + /// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs. /// Only includes files that are executable. Does NOT execute any binaries. /// @@ -595,30 +669,7 @@ pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> { let mut seen = std::collections::HashSet::new(); let mut results = Vec::new(); - let path_var = std::env::var_os("PATH").unwrap_or_default(); - let mut dirs: Vec = std::env::split_paths(&path_var).collect(); - - // Prepend the exe parent dir (Contents/MacOS/ in a .app bundle) so bundled - // providers are found even when the process PATH is minimal. - if let Ok(exe) = std::env::current_exe() { - if let Some(parent) = exe.parent() { - let parent_buf = parent.to_path_buf(); - if !dirs.contains(&parent_buf) { - dirs.insert(0, parent_buf); - } - } - } - - // Also include ~/.local/bin — the conventional location for user-installed - // provider binaries (symlinks created by install scripts). - if let Some(home) = dirs::home_dir() { - let local_bin = home.join(".local").join("bin"); - if !dirs.contains(&local_bin) { - dirs.push(local_bin); - } - } - - for dir in dirs { + for dir in executable_search_dirs() { let Ok(entries) = std::fs::read_dir(&dir) else { continue; }; @@ -637,6 +688,49 @@ pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> { results } +/// Enumerate executable `buzz-*-acp` drop-in wrappers without running them. +/// The stock `buzz-acp` command is deliberately excluded because it has no +/// namespaced middle segment and is always the editor's built-in default. +/// +/// Candidate names come from the normal executable search directories, but +/// each result is resolved through the same command resolver used at spawn. +/// This guarantees the path shown for a command is the path that command will +/// execute, even when managed shims or workspace builds take precedence. +pub fn discover_acp_command_candidates() -> Vec<(String, PathBuf)> { + discover_acp_command_candidates_in(command_discovery_dirs(), resolve_command) +} + +fn discover_acp_command_candidates_in( + dirs: impl IntoIterator, + mut resolve: impl FnMut(&str) -> Option, +) -> Vec<(String, PathBuf)> { + let mut seen = std::collections::HashSet::new(); + let mut results = Vec::new(); + + for dir in dirs { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let filename = entry.file_name().to_string_lossy().to_string(); + let Some(command) = acp_command_from_filename(&filename, cfg!(windows)) else { + continue; + }; + if !entry.path().is_file() || !is_executable(&entry.path()) { + continue; + } + if seen.insert(command.to_string()) { + if let Some(path) = resolve(command) { + results.push((command.to_string(), path)); + } + } + } + } + + results.sort_by(|left, right| left.0.cmp(&right.0)); + results +} + /// Resolve a provider ID to a discovered, executable binary path. /// /// This is the ONLY way to resolve provider binaries for execution. It: @@ -699,6 +793,14 @@ pub struct BackendProviderInfo { pub binary_path: String, } +/// A PATH-discovered drop-in wrapper for the stock `buzz-acp` harness. +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpCommandCandidate { + pub command: String, + pub binary_path: String, +} + #[cfg(test)] #[path = "backend_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/backend_tests.rs b/desktop/src-tauri/src/managed_agents/backend_tests.rs index ce1f81466f..d467f75dda 100644 --- a/desktop/src-tauri/src/managed_agents/backend_tests.rs +++ b/desktop/src-tauri/src/managed_agents/backend_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::managed_agents::discovery::command_search; #[test] fn redact_secrets_replaces_nsec() { @@ -421,6 +422,123 @@ fn provider_filename_strips_the_windows_extension() { assert_eq!(provider_id_from_filename("other"), None); } +#[test] +fn command_discovery_dirs_include_all_spawn_search_sources_without_duplicates() { + let workspace = PathBuf::from("workspace"); + let path = PathBuf::from("path"); + let managed = PathBuf::from("managed"); + let login = PathBuf::from("login"); + let nvm = PathBuf::from("nvm"); + + assert_eq!( + command_search::merge_command_discovery_dirs([ + vec![workspace.clone(), path.clone()], + vec![path, managed.clone()], + vec![login.clone()], + vec![managed, nvm.clone()], + ]), + vec![ + workspace, + PathBuf::from("path"), + PathBuf::from("managed"), + login, + nvm + ] + ); +} + +#[test] +fn acp_command_filename_supports_windows_shims_and_rejects_extensionless_windows_files() { + assert_eq!( + acp_command_from_filename("buzz-janet-acp.EXE", true), + Some("buzz-janet-acp") + ); + assert_eq!( + acp_command_from_filename("buzz-janet-acp.cmd", true), + Some("buzz-janet-acp") + ); + assert_eq!( + acp_command_from_filename("buzz-janet-acp.BAT", true), + Some("buzz-janet-acp") + ); + assert_eq!(acp_command_from_filename("buzz-janet-acp", true), None); + assert_eq!( + acp_command_from_filename("buzz-janet-acp", false), + Some("buzz-janet-acp") + ); + assert_eq!(acp_command_from_filename("buzz-acp.exe", true), None); +} + +#[test] +fn discovers_only_namespaced_acp_commands() { + let dir = tempfile::tempdir().expect("temp dir"); + for command in [ + "buzz-janet-acp", + "buzz-acp", + "buzz--acp", + "janet-acp", + "buzz-janet-helper", + ] { + let filename = if cfg!(windows) { + format!("{command}.cmd") + } else { + command.to_string() + }; + let path = dir.path().join(filename); + std::fs::write(&path, "#!/bin/sh\n").expect("write fixture"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("chmod fixture"); + } + } + + let candidates = discover_acp_command_candidates_in([dir.path().to_path_buf()], |command| { + Some(dir.path().join(command)) + }); + assert_eq!( + candidates + .into_iter() + .map(|(command, _)| command) + .collect::>(), + vec!["buzz-janet-acp"] + ); +} + +#[test] +fn acp_command_discovery_deduplicates_path_entries() { + let first = tempfile::tempdir().expect("first temp dir"); + let second = tempfile::tempdir().expect("second temp dir"); + let command = "buzz-janet-acp"; + let filename = if cfg!(windows) { + format!("{command}.cmd") + } else { + command.to_string() + }; + for dir in [first.path(), second.path()] { + let path = dir.join(&filename); + std::fs::write(&path, "#!/bin/sh\n").expect("write fixture"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("chmod fixture"); + } + } + + let resolved = second.path().join(&filename); + let candidates = discover_acp_command_candidates_in( + [first.path().to_path_buf(), second.path().to_path_buf()], + |_| Some(resolved.clone()), + ); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].0, "buzz-janet-acp"); + assert_eq!(candidates[0].1, resolved); +} + #[test] fn resolve_provider_binary_rejects_invalid_ids() { // Path traversal @@ -450,3 +568,26 @@ fn resolve_provider_binary_accepts_valid_id_format() { ), } } + +#[test] +fn portable_acp_aliases_exclude_paths_arguments_and_platform_metacharacters() { + for command in ["buzz-acp", "buzz-janet-acp", "buzz-Team_2-acp"] { + assert!(is_portable_acp_command(command), "{command}"); + } + for command in [ + "", + "buzz--acp", + "/tmp/buzz-janet-acp", + r"C:\buzz-janet-acp.cmd", + "buzz-../evil-acp", + "buzz-a b-acp", + "buzz-a&b-acp", + "buzz-a%PATH%-acp", + "buzz-a\nb-acp", + "buzz-a\u{202e}b-acp", + "other-command", + "buzz-janet-acp.exe", + ] { + assert!(!is_portable_acp_command(command), "{command:?}"); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index eca4a36bc4..d3b99b7c51 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -98,6 +98,7 @@ fn env(pairs: &[(&str, &str)]) -> BTreeMap { fn persona(id: &str, env_vars: BTreeMap) -> AgentDefinition { AgentDefinition { + acp_command: None, session_policy: Default::default(), id: id.to_string(), display_name: "P".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 3072346555..724512c4eb 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,6 +10,7 @@ use crate::managed_agents::{ }; mod auth_status_cache; mod bounded_command; +pub(crate) mod command_search; mod login_shell; mod presets; mod runtime_metadata; @@ -447,8 +448,7 @@ fn resolve_cache() -> &'static std::sync::Mutex Option { if cfg!(all(feature = "bundled-goose", target_os = "macos")) && matches!(command, "goose" | "goose-acp") diff --git a/desktop/src-tauri/src/managed_agents/discovery/command_search.rs b/desktop/src-tauri/src/managed_agents/discovery/command_search.rs new file mode 100644 index 0000000000..b65ef272c6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/command_search.rs @@ -0,0 +1,37 @@ +use super::{command_search_dirs, common_binary_paths, find_nvm_default_bin, login_shell_path}; +use std::path::PathBuf; + +pub(crate) fn merge_command_discovery_dirs( + sources: impl IntoIterator>, +) -> Vec { + sources + .into_iter() + .flatten() + .fold(Vec::new(), |mut unique, dir| { + if !unique.contains(&dir) { + unique.push(dir); + } + unique + }) +} + +pub(crate) fn command_discovery_dirs() -> Vec { + let path_dirs = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(); + let login_shell_dirs = login_shell_path() + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(); + let nvm_dirs = dirs::home_dir() + .and_then(|home| find_nvm_default_bin(&home)) + .into_iter() + .collect(); + + merge_command_discovery_dirs([ + command_search_dirs(), + path_dirs, + common_binary_paths().to_vec(), + login_shell_dirs, + nvm_dirs, + ]) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 1dbb26b450..e9352fd033 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -1,6 +1,3 @@ -use super::{discover_acp_runtimes_from, known_acp_runtime, KNOWN_ACP_RUNTIMES}; -use std::path::PathBuf; - use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ apply_agent_command_update, apply_env_vars_then_effort_transition, classify_runtime, @@ -11,7 +8,9 @@ use super::{ remove_record_effort_aliases, try_record_agent_command, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; +use super::{discover_acp_runtimes_from, known_acp_runtime, KNOWN_ACP_RUNTIMES}; use crate::managed_agents::AcpAvailabilityStatus; +use std::path::PathBuf; #[test] fn resolves_known_avatar_for_bare_command() { @@ -179,6 +178,7 @@ pub(super) fn persona_with_runtime( display_name: id.to_string(), avatar_url: None, system_prompt: String::new(), + acp_command: None, runtime: runtime.map(str::to_string), model: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 7330328fa3..41fca24828 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -14,6 +14,7 @@ fn definition( display_name: "Test Definition".to_string(), avatar_url: None, system_prompt: prompt.to_string(), + acp_command: None, runtime: None, model: model.map(str::to_string), provider: provider.map(str::to_string), diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 95ea010c92..102bce2fd6 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -368,6 +368,7 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini display_name: "Test Persona".to_string(), avatar_url: None, system_prompt: "".to_string(), + acp_command: None, runtime: None, model: model.map(str::to_string), provider: provider.map(str::to_string), @@ -632,6 +633,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { display_name: "Goose persona".to_string(), avatar_url: None, system_prompt: "".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index 13907ddc04..4da7ce7dac 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -17,6 +17,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { display_name: display_name.to_string(), avatar_url: None, system_prompt: String::new(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e37805f2b6..73855c368f 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -137,6 +137,7 @@ mod tests { display_name: String::new(), avatar_url: None, system_prompt: String::new(), + acp_command: None, runtime: runtime.map(str::to_string), model: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 25ebb78d6f..e2ed83f39b 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -59,8 +59,8 @@ const PUBLISH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); /// The JSON body stored in a persona event's content field. /// /// Field order MUST match the NIP-AP reference vectors (`docs/nips/NIP-AP.md` -/// content body: `display_name, system_prompt, avatar_url, runtime, model, -/// provider, name_pool`). serde emits fields in declaration order, so this +/// content body: `display_name, system_prompt, acp_command, avatar_url, runtime, +/// model, provider, name_pool, respond_to, respond_to_allowlist, parallelism`). serde emits fields in declaration order, so this /// order pins the exact content bytes and therefore the NIP-01 event id — a /// reorder here breaks cross-implementation interop. Guarded by /// `content_matches_nip_ap_vector`. @@ -74,6 +74,8 @@ pub struct PersonaEventContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub system_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub avatar_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime: Option, @@ -188,7 +190,18 @@ pub fn monotonic_created_at(prior_head_created_at: Option) -> nostr::Timest pub fn build_persona_event(record: &AgentDefinition) -> Result { // Single projection point — persona_event_content owns the field mapping // (and the hash-stability rules that come with it). - let content = persona_event_content(record); + let mut content = persona_event_content(record); + if record.shared { + // Public catalog heads carry only portable aliases. Explicit stock + // distinguishes a reset from an omitted machine-local owner override. + content.acp_command = match record.acp_command.as_deref() { + None => Some(super::DEFAULT_ACP_COMMAND.to_string()), + Some(command) if super::backend::is_portable_acp_command(command) => { + Some(command.to_string()) + } + Some(_) => None, + }; + } let content_json = serde_json::to_string(&content) .map_err(|e| format!("failed to serialize persona content: {e}"))?; @@ -243,6 +256,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result PersonaEventContent { // records serialize byte-identically and persona_content_hash is // stable across the upgrade (drift badges must not flip). system_prompt: Some(record.system_prompt.clone()), + acp_command: record.acp_command.clone(), runtime: record.runtime.clone(), model: record.model.clone(), provider: record.provider.clone(), @@ -561,6 +576,7 @@ pub fn persona_event_content(record: &AgentDefinition) -> PersonaEventContent { /// the definition's current content hash. pub struct PersonaSnapshot { pub system_prompt: Option, + pub acp_command: Option, pub model: Option, pub provider: Option, /// Preferred ACP runtime ID, copied verbatim from the persona (including @@ -582,6 +598,7 @@ pub struct PersonaSnapshot { pub fn persona_snapshot(persona: &AgentDefinition) -> PersonaSnapshot { PersonaSnapshot { system_prompt: Some(persona.system_prompt.clone()), + acp_command: persona.acp_command.clone(), model: persona.model.clone(), provider: persona.provider.clone(), runtime: persona.runtime.clone(), @@ -612,6 +629,11 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe if let Some(prompt) = snapshot.system_prompt { record.system_prompt = Some(prompt); } + // The definition view omits stock buzz-acp. Absence therefore resets a + // previously selected wrapper; preserving the instance would resurrect it. + record.acp_command = snapshot + .acp_command + .unwrap_or_else(|| super::DEFAULT_ACP_COMMAND.to_string()); record.model = snapshot.model; record.provider = snapshot.provider; record.runtime = snapshot.runtime; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 5f09d6e475..1beec3d052 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -152,6 +152,7 @@ pub(super) fn sample_persona() -> AgentDefinition { display_name: "Test Persona".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), system_prompt: "You are a test assistant.".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: Some("claude-opus-4".to_string()), provider: Some("anthropic".to_string()), @@ -283,7 +284,8 @@ fn shared_persona_event_has_exact_tag_and_round_trips() { #[test] fn round_trip_serialization() { - let record = sample_persona(); + let mut record = sample_persona(); + record.acp_command = Some("buzz-janet-acp".to_string()); let builder = build_persona_event(&record).unwrap(); let keys = nostr::Keys::generate(); let event = builder.sign_with_keys(&keys).unwrap(); @@ -296,6 +298,7 @@ fn round_trip_serialization() { Some("https://example.com/avatar.png".to_string()) ); assert_eq!(restored.system_prompt, "You are a test assistant."); + assert_eq!(restored.acp_command.as_deref(), Some("buzz-janet-acp")); assert_eq!(restored.runtime, Some("goose".to_string())); assert_eq!(restored.model, Some("claude-opus-4".to_string())); assert_eq!(restored.provider, Some("anthropic".to_string())); @@ -327,6 +330,7 @@ fn content_matches_nip_ap_vector() { description: None, display_name: "Test Agent".to_string(), system_prompt: Some("You are a test assistant.".to_string()), + acp_command: None, avatar_url: Some("https://example.com/avatar.png".to_string()), runtime: Some("goose".to_string()), model: Some("claude-opus-4".to_string()), @@ -342,6 +346,16 @@ fn content_matches_nip_ap_vector() { "serialized content drifted from the NIP-AP Event 1 vector" ); + let mut with_transport = content.clone(); + with_transport.acp_command = Some("buzz-janet-acp".into()); + assert_eq!( + serde_json::to_string(&with_transport).unwrap(), + VECTOR.replace( + "\"avatar_url\":", + "\"acp_command\":\"buzz-janet-acp\",\"avatar_url\":" + ) + ); + // Hash invariance across the unified-model widening: REAL pre-revision // content bytes (fixture string, not a round-trip through the new // struct) must parse and re-serialize byte-identically, so @@ -384,6 +398,7 @@ fn content_matches_nip_ap_vector() { display_name: "Test Agent".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), system_prompt: "You are a test assistant.".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: Some("claude-opus-4".to_string()), provider: Some("anthropic".to_string()), @@ -418,6 +433,7 @@ fn round_trip_minimal_persona() { display_name: "Minimal".to_string(), avatar_url: None, system_prompt: "Hello".to_string(), + acp_command: None, runtime: None, model: None, provider: None, @@ -518,6 +534,7 @@ fn quad_absent_definition_hash_stable_across_activation() { display_name: "Test".to_string(), avatar_url: None, system_prompt: "Hello".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: Some("gpt-oss".to_string()), provider: None, @@ -566,6 +583,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef display_name: content.display_name, avatar_url: content.avatar_url, system_prompt: content.system_prompt.unwrap_or_default(), + acp_command: content.acp_command, runtime: content.runtime, model: content.model, provider: content.provider, @@ -594,6 +612,7 @@ fn persona_content_hash_is_deterministic() { display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), + acp_command: None, runtime: None, model: None, provider: None, @@ -616,6 +635,7 @@ fn persona_content_hash_changes_on_edit() { display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), + acp_command: None, runtime: None, model: None, provider: None, @@ -693,6 +713,7 @@ fn description_change_does_not_change_content_hash() { display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), + acp_command: None, runtime: None, model: None, provider: None, @@ -718,6 +739,34 @@ fn description_change_does_not_change_content_hash() { ); } +#[test] +fn snapshot_applies_persona_acp_command_to_linked_instance() { + let mut record = sample_record(); + let mut persona = sample_persona(); + persona.acp_command = Some("buzz-janet-acp".to_string()); + + apply_persona_snapshot(&mut record, &persona); + + assert_eq!(record.acp_command, "buzz-janet-acp"); + + // Saving stock transport round-trips through the unified store as None. + persona.acp_command = Some("buzz-acp".to_string()); + let restored = persona + .clone() + .into_agent_record() + .to_definition_view() + .unwrap(); + assert_eq!(restored.acp_command, None); + apply_persona_snapshot(&mut record, &restored); + assert_eq!(record.acp_command, "buzz-acp"); + + // Owner-controlled legacy custom commands remain definition state. + persona.acp_command = Some("/opt/custom-acp".to_string()); + let restored = persona.into_agent_record().to_definition_view().unwrap(); + apply_persona_snapshot(&mut record, &restored); + assert_eq!(record.acp_command, "/opt/custom-acp"); +} + // ── PersonaSnapshot.runtime ─────────────────────────────────────────────── /// (b) The snapshot carries the persona's runtime VERBATIM — including None, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 7753c72335..43ad666dba 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -126,6 +126,7 @@ fn built_in_persona_records(now: &str) -> Vec { avatar_url: persona.avatar_url.map(|s| s.to_string()), description: None, system_prompt: persona.system_prompt.to_string(), + acp_command: None, runtime: persona.runtime.map(|s| s.to_string()), model: persona.model.map(|s| s.to_string()), provider: None, diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 4153445444..fbf4492abd 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -14,6 +14,7 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { display_name: display_name.to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), system_prompt: "Custom prompt".to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 39247f7a15..1b416c4768 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,4 @@ use crate::managed_agents::known_acp_runtime; - #[path = "cli_tests.rs"] mod cli_tests; @@ -278,6 +277,7 @@ fn persona_with_provider( display_name: id.to_string(), avatar_url: None, system_prompt: prompt.to_string(), + acp_command: None, runtime: None, model: model.map(str::to_string), provider: provider.map(str::to_string), diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 36f0c77722..5ad617fde5 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -111,6 +111,7 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { display_name: id.into(), avatar_url: None, system_prompt: prompt.into(), + acp_command: None, runtime: runtime.map(str::to_string), model: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/team_catalog.rs b/desktop/src-tauri/src/managed_agents/team_catalog.rs index 0a54086d7e..99961fcc63 100644 --- a/desktop/src-tauri/src/managed_agents/team_catalog.rs +++ b/desktop/src-tauri/src/managed_agents/team_catalog.rs @@ -132,6 +132,9 @@ pub struct TeamCatalogMember { pub avatar_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime: Option, + /// Portable ACP transport alias, omitted for legacy machine-local commands. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_command: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -305,6 +308,10 @@ fn member_projection(record: &AgentDefinition) -> TeamCatalogMember { system_prompt: Some(record.system_prompt.clone()), avatar_url, runtime: record.runtime.clone(), + acp_command: record + .acp_command + .clone() + .filter(|command| super::is_portable_acp_command(command)), model: record.model.clone(), provider: record.provider.clone(), name_pool: record.name_pool.clone(), @@ -487,6 +494,7 @@ fn non_empty(value: &str, label: &str) -> Result<(), String> { /// rejected it out of 1..=32. Validating at the parse boundary makes an /// unusable team un-addable instead of add-then-broken. fn validate_member(member: &TeamCatalogMember) -> Result<(), String> { + super::validate_portable_acp_command(member.acp_command.as_deref())?; let who = &member.display_name; non_empty(&member.member_key, "a member key")?; bounded(&member.member_key, MAX_MEMBER_KEY_BYTES, "a member key")?; diff --git a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs index 16235c2576..13a93a8d19 100644 --- a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs +++ b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs @@ -1,5 +1,6 @@ use super::*; use std::{collections::BTreeMap, path::PathBuf}; +mod acp_transport; mod concealment; // executable-text concealment gate (Carl P1) mod reuse_hint; // built-in reuse-hint projection-hash boundary gate (Carl r9 P1) @@ -11,6 +12,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: Some("claude-opus-4".to_string()), provider: Some("anthropic".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/team_catalog/tests/acp_transport.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests/acp_transport.rs new file mode 100644 index 0000000000..19dbbcec76 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/team_catalog/tests/acp_transport.rs @@ -0,0 +1,28 @@ +use super::*; + +#[test] +fn portable_transport_is_projected_validated_and_hashed() { + let mut one = member("m1", "One"); + let legacy_hash = local_member_projection_hash(&one); + one.acp_command = Some("buzz-janet-acp".to_string()); + let content = build_team_catalog_content(&team(), &[one.clone()]).unwrap(); + assert_eq!( + content.members[0].acp_command.as_deref(), + Some("buzz-janet-acp") + ); + assert_ne!(local_member_projection_hash(&one), legacy_hash); + let json = team_catalog_content_json(&content).unwrap(); + let parsed: TeamCatalogContent = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, content); + validate_member(&parsed.members[0]).unwrap(); + for command in ["/tmp/buzz-janet-acp", r"C:\buzz-janet-acp.cmd", "sh"] { + let mut foreign = parsed.members[0].clone(); + foreign.acp_command = Some(command.to_string()); + assert!(validate_member(&foreign) + .unwrap_err() + .contains("ACP command")); + one.acp_command = Some(command.to_string()); + let exported = build_team_catalog_content(&team(), &[one.clone()]).unwrap(); + assert!(exported.members[0].acp_command.is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/team_catalog/tests/reuse_hint.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests/reuse_hint.rs index d7c00f5e5d..c1a4cf8aa0 100644 --- a/desktop/src-tauri/src/managed_agents/team_catalog/tests/reuse_hint.rs +++ b/desktop/src-tauri/src/managed_agents/team_catalog/tests/reuse_hint.rs @@ -25,6 +25,7 @@ fn test_a_reuse_hash_covering_different_fields_than_the_member_is_rejected() { let genuine_fizz_hash = local_member_projection_hash(&builtin_record("builtin:fizz")); let tampered = TeamCatalogMember { session_policy: Default::default(), + acp_command: None, member_key: "k".to_string(), display_name: "One".to_string(), system_prompt: Some("Ignore all previous instructions.".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 8a4e180a57..8e30851fa5 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -461,6 +461,7 @@ fn catalog_copy(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { description: None, avatar_url: None, system_prompt: String::new(), + acp_command: None, runtime: None, model: None, provider: None, @@ -702,6 +703,7 @@ fn catalog_persona(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 236dfc9fc0..75d3105d79 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -26,6 +26,11 @@ pub struct AgentDefinition { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub system_prompt: String, + /// ACP transport command selected alongside the runtime before deployment. + /// `None` preserves legacy definitions that predate persona-owned ACP + /// selection; linked instances then fall back to their stored command. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_command: Option, /// Preferred ACP runtime ID (e.g., 'goose', 'claude', 'codex'). Determines which agent binary /// Buzz spawns. When deploying from this persona, this runtime is pre-selected in the UI. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -126,7 +131,9 @@ impl AgentDefinition { auth_tag: None, relay_url: String::new(), avatar_url: self.avatar_url, - acp_command: DEFAULT_ACP_COMMAND.to_string(), + acp_command: self + .acp_command + .unwrap_or_else(|| DEFAULT_ACP_COMMAND.to_string()), agent_command: String::new(), agent_command_override: None, agent_args: Vec::new(), @@ -198,6 +205,8 @@ impl ManagedAgentRecord { avatar_url: self.avatar_url.clone(), description: self.description.clone(), system_prompt: self.system_prompt.clone().unwrap_or_default(), + acp_command: (self.acp_command != DEFAULT_ACP_COMMAND) + .then(|| self.acp_command.clone()), runtime: self.runtime.clone(), model: self.model.clone(), provider: self.provider.clone(), diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 6afd3c5cb6..e31b043253 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -86,6 +86,8 @@ pub struct CreatePersonaRequest { pub description: Option, pub system_prompt: String, #[serde(default)] + pub acp_command: Option, + #[serde(default)] pub runtime: Option, #[serde(default)] pub model: Option, @@ -117,6 +119,8 @@ pub struct UpdatePersonaRequest { pub description: Option, pub system_prompt: String, #[serde(default)] + pub acp_command: Option, + #[serde(default)] pub runtime: Option, #[serde(default)] pub model: Option, @@ -297,6 +301,7 @@ mod tests { display_name: "Test".to_string(), avatar_url: None, system_prompt: "prompt".to_string(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 5f62487447..82d2a25239 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -507,6 +507,7 @@ fn sample_persona() -> AgentDefinition { display_name: "Helper".to_string(), avatar_url: Some("https://example.com/a.png".to_string()), system_prompt: "You help.".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: Some("gpt-x".to_string()), provider: Some("openai".to_string()), @@ -583,6 +584,20 @@ fn persona_into_agent_record_is_keyless_and_slugged() { assert_eq!(record.env_vars.get("K").map(String::as_str), Some("v")); } +#[test] +fn alternate_acp_command_survives_the_agent_store_fold() { + let mut persona = sample_persona(); + persona.acp_command = Some("buzz-janet-acp".to_string()); + + let view = persona + .clone() + .into_agent_record() + .to_definition_view() + .expect("slugged record must present a persona view"); + + assert_eq!(view.acp_command, persona.acp_command); +} + #[test] fn persona_view_round_trips_through_agent_record() { let persona = sample_persona(); diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 48328935b0..57742ccf52 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -445,6 +445,7 @@ mod tests { avatar_url: None, description: None, system_prompt: String::new(), + acp_command: None, runtime: None, model: None, provider: None, diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 579a4c20fb..8deca18df0 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -31,6 +31,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati display_name: "Fizz".to_string(), avatar_url: Some(old_fizz.to_string()), system_prompt: "A customized built-in prompt".to_string(), + acp_command: None, runtime: Some("goose".to_string()), model: Some("test-model".to_string()), provider: Some("test-provider".to_string()), diff --git a/desktop/src-tauri/src/persona_catalog.rs b/desktop/src-tauri/src/persona_catalog.rs index c7976b06e3..927e5386bb 100644 --- a/desktop/src-tauri/src/persona_catalog.rs +++ b/desktop/src-tauri/src/persona_catalog.rs @@ -54,6 +54,7 @@ struct CatalogAgentProjection { description: Option, system_prompt: String, runtime: Option, + acp_command: Option, model: Option, provider: Option, name_pool: Vec, @@ -239,6 +240,12 @@ fn parse_agent(content: &str) -> Option { }; validate_agent_description_text(raw_description.as_deref()).ok()?; let description = raw_description.filter(|value| !value.trim().is_empty()); + let acp_command = match object.get("acp_command") { + None | Some(Value::Null) => None, + Some(Value::String(command)) => Some(command.clone()), + _ => return None, + }; + crate::managed_agents::validate_portable_acp_command(acp_command.as_deref()).ok()?; let respond_to = match object.get("respond_to").and_then(Value::as_str) { Some("allowlist") => Some("owner-only".to_string()), @@ -275,6 +282,7 @@ fn parse_agent(content: &str) -> Option { description, system_prompt, runtime: optional_string(object.get("runtime")), + acp_command, model: optional_string(object.get("model")), provider: optional_string(object.get("provider")), name_pool, diff --git a/desktop/src-tauri/src/persona_catalog_tests.rs b/desktop/src-tauri/src/persona_catalog_tests.rs index a6d6e519e5..38da64896e 100644 --- a/desktop/src-tauri/src/persona_catalog_tests.rs +++ b/desktop/src-tauri/src/persona_catalog_tests.rs @@ -249,6 +249,7 @@ fn serialized_catalog_matches_the_typescript_contract() { source_persona_id: "persona-1".into(), created_at: 42, agent: CatalogAgentProjection { + acp_command: Some("buzz-janet-acp".into()), display_name: "Ada".into(), avatar_url: Some("https://example.com/a.png".into()), description: Some("A kind agent.".into()), @@ -274,6 +275,7 @@ fn serialized_catalog_matches_the_typescript_contract() { "description": "A kind agent.", "systemPrompt": "be kind", "runtime": "acp", + "acpCommand": "buzz-janet-acp", "model": "m1", "provider": "p1", "namePool": ["Ada", "Lin"], @@ -284,3 +286,61 @@ fn serialized_catalog_matches_the_typescript_contract() { }]); assert_eq!(actual, expected); } + +#[test] +fn catalog_preserves_portable_acp_alias_and_rejects_nonportable_values() { + let mut content = valid_content("Reviewer"); + assert_eq!(parse_agent(&content.to_string()).unwrap().acp_command, None); + for command in ["buzz-acp", "buzz-janet-acp"] { + content["acp_command"] = json!(command); + let projected = parse_agent(&content.to_string()).unwrap(); + assert_eq!(projected.acp_command.as_deref(), Some(command)); + assert_eq!( + serde_json::to_value(projected).unwrap()["acpCommand"], + command + ); + } + for command in [ + json!("/tmp/buzz-janet-acp"), + json!(r"C:\buzz-janet-acp.cmd"), + json!("sh"), + json!("buzz-a&b-acp"), + json!(7), + ] { + content["acp_command"] = command; + assert!(parse_agent(&content.to_string()).is_none()); + } +} + +#[test] +fn shared_persona_publication_is_portable_and_catalog_readable() { + use crate::managed_agents::persona_events::{build_persona_event, persona_from_event}; + let keys = Keys::generate(); + let mut content = valid_content("Reviewer"); + content["name_pool"] = json!(["Reviewer"]); + let seed = event(&keys, 1, "reviewer", false, content); + let mut persona = persona_from_event(&seed).unwrap(); + persona.shared = true; + for (command, projected) in [ + (Some("/opt/custom-acp"), None), + (Some("buzz-janet-acp"), Some("buzz-janet-acp")), + (Some("buzz-acp"), Some("buzz-acp")), + (None, Some("buzz-acp")), + ] { + persona.acp_command = command.map(str::to_string); + let published = build_persona_event(&persona) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + published.verify().unwrap(); + assert!(!published.content.contains("/opt/custom-acp")); + let publications = publications_from_verified_events(vec![published]); + assert_eq!(publications.len(), 1); + assert_eq!(publications[0].agent.acp_command.as_deref(), projected); + assert_eq!( + persona.acp_command.as_deref(), + command, + "publication must not mutate local state" + ); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 652252b2a5..a4a573cde8 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.24", + "version": "0.5.25", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b4c2039023..f9cfd5790e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -173,8 +173,17 @@ export function AppShell() { }); // Settings lives in history so back returns to the previous app entry. const settingsOpen = location.pathname === "/settings"; - const locationSearchSection = (location.search as { section?: unknown }) + const rawLocationSearchSection = (location.search as { section?: unknown }) .section; + // Migrate the legacy "moderation" token to "relay-admin" (renamed section + // id). useLocation().search is the raw URL query, bypassing route validation, + // so the alias must be applied here too. The "doctor" alias is NOT needed on + // this path: AppShell never carried it on main, and it lives only in + // validateSettingsSearch where route validation rewrites it before rendering. + const locationSearchSection = + rawLocationSearchSection === "moderation" + ? "relay-admin" + : rawLocationSearchSection; const settingsSection: SettingsSection = isSettingsSection( locationSearchSection, ) diff --git a/desktop/src/app/routes/settings.test.mjs b/desktop/src/app/routes/settings.test.mjs new file mode 100644 index 0000000000..9373fccf10 --- /dev/null +++ b/desktop/src/app/routes/settings.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Import the real validateSettingsSearch so tests exercise actual production +// route-validation logic, not a mounted component (mounting SettingsView +// directly bypasses validateSettingsSearch and is what let the earlier gap +// slip past the existing test). +const { validateSettingsSearch } = await import("./settings.tsx"); + +test("?section=moderation migrates to relay-admin", () => { + const result = validateSettingsSearch({ section: "moderation" }); + assert.equal( + result.section, + "relay-admin", + "legacy ?section=moderation must redirect to relay-admin, not fall through to undefined", + ); +}); + +test("?section=relay-admin is accepted as-is", () => { + const result = validateSettingsSearch({ section: "relay-admin" }); + assert.equal(result.section, "relay-admin"); +}); + +test("?section=doctor still migrates to agents", () => { + const result = validateSettingsSearch({ section: "doctor" }); + assert.equal(result.section, "agents"); +}); + +test("valid section passes through unchanged", () => { + const result = validateSettingsSearch({ section: "profile" }); + assert.equal(result.section, "profile"); +}); + +test("unknown section resolves to undefined (falls back to default)", () => { + const result = validateSettingsSearch({ section: "totally-unknown-value" }); + assert.equal(result.section, undefined); +}); + +test("missing section resolves to undefined", () => { + const result = validateSettingsSearch({}); + assert.equal(result.section, undefined); +}); diff --git a/desktop/src/app/routes/settings.tsx b/desktop/src/app/routes/settings.tsx index 0011018830..14df89fb0a 100644 --- a/desktop/src/app/routes/settings.tsx +++ b/desktop/src/app/routes/settings.tsx @@ -9,13 +9,17 @@ type SettingsRouteSearch = { section?: SettingsSection; }; -function validateSettingsSearch( +export function validateSettingsSearch( search: Record, ): SettingsRouteSearch { if (search.section === "doctor") { return { section: "agents" }; } + if (search.section === "moderation") { + return { section: "relay-admin" }; + } + return { section: isSettingsSection(search.section) ? search.section : undefined, }; diff --git a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx new file mode 100644 index 0000000000..417e9d99a9 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx @@ -0,0 +1,578 @@ +/** + * Feedback tab — shows the deployment-wide product feedback queue with + * optional image attachment viewer and status triage controls. + * + * The attachment viewer uses a per-load generation fence to discard results + * from superseded loads on identity/origin change or unmount. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { AlertCircle, ChevronLeft, Download, LoaderCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; +import { cn } from "@/shared/lib/cn"; +import { + fetchAdminAttachmentBlobUrl, + saveAdminAttachment, + getAdminFeedback, + listAdminFeedback, + patchAdminFeedback, + type AdminAttachmentErrorCode, + type AdminFeedbackDto, + type AdminFeedbackStatus, + type AdminFeedbackSummaryDto, +} from "./api"; +import { + type AsyncState, + type AttachmentMeta, + adminErrorMessage, + CommunityGroupedList, + DetailRow, + ErrorMessage, + LoadingSpinner, + formatTimestamp, + parseImetaAttachments, + useAsyncLoad, +} from "./AdminConsolePanelHelpers"; + +// ── Attachment budget ───────────────────────────────────────────────────── + +/** + * Per-item attachment display limits — defence against a hostile relay + * crafting a feedback entry with hundreds of large attachments to force + * unbounded concurrent fetches and memory pressure. + * + * MAX_FEEDBACK_ATTACHMENTS: maximum number of attachments rendered per item. + * Extra entries are silently dropped and a notice is shown to the operator. + * + * MAX_FEEDBACK_ATTACHMENT_AGGREGATE_BYTES: aggregate byte ceiling across all + * rendered attachments (sum of imeta `size` values). Attachments that would + * push the running total over this limit are excluded; earlier entries in the + * list take priority. Combined with the per-response 10 MiB cap enforced by + * fetchAdminAttachmentBlobUrl, the worst case per item is: + * min(MAX_FEEDBACK_ATTACHMENTS, floor(MAX_FEEDBACK_ATTACHMENT_AGGREGATE_BYTES / 1)) fetches + * of at most 10 MiB each. + */ +const MAX_FEEDBACK_ATTACHMENTS = 5; +const MAX_FEEDBACK_ATTACHMENT_AGGREGATE_BYTES = 50 * 1024 * 1024; // 50 MiB + +/** + * Apply count and aggregate-byte limits to a parsed attachment list. + * Returns `{ shown, truncated }` where `truncated` is the number of entries + * that were dropped. Attachment order is preserved; earlier entries win when + * the aggregate limit is hit. + */ +export function applyAttachmentBudget( + attachments: AttachmentMeta[], + maxCount = MAX_FEEDBACK_ATTACHMENTS, + maxBytes = MAX_FEEDBACK_ATTACHMENT_AGGREGATE_BYTES, +): { shown: AttachmentMeta[]; truncated: number } { + const shown: AttachmentMeta[] = []; + let runningBytes = 0; + for (const a of attachments) { + if (shown.length >= maxCount) break; + if (runningBytes + a.size > maxBytes) break; + shown.push(a); + runningBytes += a.size; + } + return { shown, truncated: attachments.length - shown.length }; +} + +// ── Feedback tab ────────────────────────────────────────────────────────── + +export function FeedbackTab({ + canMutate, + origin, + pubkey, + generation, +}: { + canMutate: boolean; + origin: string; + pubkey: string; + generation: number; +}) { + const [selectedId, setSelectedId] = useState(null); + // List refresh fence: bumped when a feedback status change completes in the + // detail view, so returning to the list shows fresh status without a tab + // switch. + const [listGen, setListGen] = useState(0); + + const listState: AsyncState = useAsyncLoad( + () => listAdminFeedback(origin), + [origin, pubkey], + generation + listGen, + ); + + if (selectedId) { + return ( + setSelectedId(null)} + origin={origin} + pubkey={pubkey} + generation={generation} + onMutated={() => setListGen((g) => g + 1)} + /> + ); + } + + if (listState.status === "loading") return ; + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const items = listState.data; + if (!Array.isArray(items) || items.length === 0) { + return

No feedback found.

; + } + + return ( + { + const id = item.id; + const text = item.bodySummary.slice(0, 120); + const receivedAt = item.receivedAt; + const status = item.status; + return ( +
  • + +
  • + ); + }} + /> + ); +} + +// ── Attachment viewer ───────────────────────────────────────────────────── + +function AttachmentViewer({ + origin, + pubkey, + feedbackId, + attachment, + panelGeneration, +}: { + origin: string; + pubkey: string; + feedbackId: string; + attachment: AttachmentMeta; + /** Generation from the parent panel — when this changes the attachment + * context has changed and any in-flight load result is stale. */ + panelGeneration: number; +}) { + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState( + null, + ); + const blobUrlRef = useRef(null); + // Per-load generation: incremented when a new load starts AND in cleanup so + // that unmount or panelGeneration change invalidates any in-flight load. + const loadGenRef = useRef(0); + // Keep current origin/pubkey in refs so the callback can compare against + // the rendered-at-call-time values without capturing stale closure copies. + const originRef = useRef(origin); + const pubkeyRef = useRef(pubkey); + originRef.current = origin; + pubkeyRef.current = pubkey; + + // On panelGeneration change (identity/origin switch) or unmount: + // invalidate any in-flight load and revoke the cached blob URL. + // biome-ignore lint/correctness/useExhaustiveDependencies: panelGeneration is a prop that drives cleanup re-registration; the cleanup body mutates refs, not reactive state + useEffect(() => { + return () => { + // Increment generation so any in-flight native callback sees a mismatch. + loadGenRef.current += 1; + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, [panelGeneration]); + + const load = useCallback(async () => { + // Capture snapshot of context at the moment this load starts. + const thisGen = ++loadGenRef.current; + const thisOrigin = origin; + const thisPubkey = pubkey; + + setError(null); + try { + const url = await fetchAdminAttachmentBlobUrl( + origin, + feedbackId, + attachment.sha256, + attachment.mime, + attachment.size, + ); + + // Discard if a newer load started, the component was unmounted/context + // changed (loadGenRef incremented in cleanup), or origin/pubkey differ. + if ( + thisGen !== loadGenRef.current || + thisOrigin !== originRef.current || + thisPubkey !== pubkeyRef.current + ) { + URL.revokeObjectURL(url); + return; + } + + // Revoke any previous blob before replacing. + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = url; + setBlobUrl(url); + } catch (e) { + if (thisGen !== loadGenRef.current) return; + setError( + typeof e === "string" ? (e as AdminAttachmentErrorCode) : String(e), + ); + } + }, [ + origin, + pubkey, + feedbackId, + attachment.sha256, + attachment.mime, + attachment.size, + ]); + + // Auto-load image/* attachments immediately on mount — no button click needed. + // Routes through the same `load` callback (generation fence, SSRF guard, + // revoke-on-cleanup), so the existing blob-leak tests remain valid and cover + // the auto-load path. + // biome-ignore lint/correctness/useExhaustiveDependencies: load is a stable useCallback; attachment.mime is a mount-time constant — auto-load fires once per mount + useEffect(() => { + if (attachment.mime.startsWith("image/")) { + void load(); + } + }, []); // Empty: fires once on mount; identity boundary and generation fence handle context changes. + + // Save the attachment via the native save dialog — fetches fresh bytes + // from the relay and writes them to the user-chosen path. This path is + // used for non-image types where `
    ` is a no-op + // in WKWebView. + const save = useCallback(async () => { + setSaving(true); + setSaveError(null); + try { + const saved = await saveAdminAttachment( + origin, + feedbackId, + attachment.sha256, + attachment.mime, + attachment.size, + ); + if (saved) { + toast.success("Attachment saved"); + } + } catch (e) { + setSaveError( + typeof e === "string" ? (e as AdminAttachmentErrorCode) : String(e), + ); + } finally { + setSaving(false); + } + }, [origin, feedbackId, attachment.sha256, attachment.mime, attachment.size]); + + if (error) { + return ; + } + + // Non-image types are never previewed: Save writes them through the native + // dialog (a blob `` is a no-op in WKWebView). + if (!attachment.mime.startsWith("image/")) { + return ( +
    + + {saveError ? : null} +
    + ); + } + + if (!blobUrl) { + return ( +
    + + Loading… +
    + ); + } + + return ( + Feedback attachment + ); +} + +// ── Feedback fields ─────────────────────────────────────────────────────── + +function FeedbackFields({ data }: { data: AdminFeedbackDto }) { + return ( +
    + + + + + + + + + +
    + ); +} + +// ── Feedback status control ─────────────────────────────────────────────── + +/** + * Status-control widget for a feedback entry. + * Lets operators/moderators triage feedback by marking it as + * `reviewed` or `archived` (or reverting to `new`). + */ +function FeedbackStatusControl({ + feedbackId, + currentStatus, + origin, + onStatusChanged, +}: { + feedbackId: string; + currentStatus: AdminFeedbackStatus | null; + origin: string; + onStatusChanged: (newStatus: AdminFeedbackStatus) => void; +}) { + const [isWorking, setIsWorking] = useState(false); + + const statuses: AdminFeedbackStatus[] = ["new", "reviewed", "archived"]; + + const handleStatusChange = async (newStatus: AdminFeedbackStatus) => { + if (newStatus === currentStatus) return; + setIsWorking(true); + try { + await patchAdminFeedback(origin, feedbackId, newStatus); + toast.success(`Feedback marked ${newStatus}`); + onStatusChanged(newStatus); + } catch (e) { + toast.error(adminErrorMessage(e)); + } finally { + setIsWorking(false); + } + }; + + return ( +
    + Status +
    + {statuses.map((s) => ( + + ))} +
    +
    + ); +} + +// ── Feedback detail ─────────────────────────────────────────────────────── + +export function FeedbackDetail({ + canMutate, + origin, + pubkey, + generation, + feedbackId, + onBack, + onMutated, +}: { + canMutate: boolean; + origin: string; + pubkey: string; + generation: number; + feedbackId: string; + onBack: () => void; + /** Called after a status change completes so the parent list can refetch. */ + onMutated: () => void; +}) { + // Local status state: initialized from server, updated on PATCH. + const [localStatus, setLocalStatus] = useState( + null, + ); + + const detailState: AsyncState = useAsyncLoad( + () => getAdminFeedback(origin, feedbackId), + [origin, pubkey, feedbackId], + generation, + ); + + // Sync localStatus from server data on load (but not on every re-render). + // `status` is a required wire field — read it directly, never default a + // missing value to "new" (that would misreport a reviewed/archived entry). + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — sync once when data arrives + useEffect(() => { + if (detailState.status === "ok") { + setLocalStatus(detailState.data.status); + } + }, [detailState.status === "ok"]); + + // Parse imeta attachment metadata from the relay's wire `tags: string[][]`. + // AdminFeedback is serialised camelCase by the relay (serde rename_all). + // Apply count and aggregate-byte limits before rendering — a hostile relay + // could craft an entry with many large attachments to force unbounded fetches. + const allAttachments: AttachmentMeta[] = + detailState.status === "ok" + ? parseImetaAttachments(detailState.data.tags) + : []; + const { shown: attachments, truncated: truncatedCount } = + applyAttachmentBudget(allAttachments); + + return ( +
    + + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( + <> + + {canMutate ? ( + { + setLocalStatus(newStatus); + onMutated(); + }} + /> + ) : ( + localStatus !== null && ( +
    + + Status + + {localStatus} +
    + ) + )} + {attachments.length > 0 && ( +
    +

    Attachments

    + {attachments.map((a) => ( + + ))} + {truncatedCount > 0 && ( +

    + {truncatedCount} attachment + {truncatedCount === 1 ? "" : "s"} not shown (display limit + reached). +

    + )} +
    + )} + + )} +
    + ); +} + +const FRIENDLY_ATTACHMENT_ERROR: Record = { + admin_attachment_too_large: "Attachment exceeds the 10 MiB desktop cap.", + admin_attachment_mime_mismatch: + "Attachment MIME type does not match the imeta record.", + admin_attachment_size_mismatch: + "Attachment byte count does not match the imeta record.", + admin_attachment_network_error: "Network error fetching attachment.", +}; + +function AttachmentError({ code }: { code: AdminAttachmentErrorCode }) { + return ( +
    + + {FRIENDLY_ATTACHMENT_ERROR[code] ?? `Error: ${code}`} +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx new file mode 100644 index 0000000000..0af4facfe3 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -0,0 +1,189 @@ +/** + * Main admin console panel — renders when probe state is `nip98Authorized` or + * `disabled`. + * + * Shows three tabs: Reports (deployment-wide moderation reports), Feedback + * (product feedback with optional image attachments), and Staffing (Operator- + * only operator management). + * + * All query/UI state is keyed by `(pubkey, origin)`. In-flight native requests + * are fenced by an effect-local `active` flag that is set to `false` in the + * effect cleanup, ensuring stale results are discarded on arrival. + * + * Tauri invoke is not cancellable at the native layer, but the active-flag + * pattern ensures stale results never update visible state or create + * unreachable blob URLs. + * + * Sub-components live in adjacent files: + * - AdminConsolePanelHelpers.tsx — AsyncState, useAsyncLoad, formatTimestamp, + * DetailRow, LoadingSpinner, ErrorMessage, + * AttachmentMeta, parseImetaAttachments + * - AdminConsoleFeedbackTab.tsx — FeedbackTab, FeedbackDetail + * - AdminConsoleStaffingTab.tsx — StaffingTab + */ + +import { useEffect, useRef, useState } from "react"; +import { MessageSquare, ShieldAlert, Users } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; +import type { AdminPrincipalRole } from "./api"; +import { ReportsTab } from "./AdminConsoleReportsTab"; +import { FeedbackTab } from "./AdminConsoleFeedbackTab"; +import { StaffingTab } from "./AdminConsoleStaffingTab"; + +export { + parseImetaAttachments, + type AttachmentMeta, +} from "./AdminConsolePanelHelpers"; + +// ── Tab bar ─────────────────────────────────────────────────────────────── + +type Tab = "reports" | "feedback" | "staffing"; + +function TabBar({ + activeTab, + onSelect, + showStaffing, +}: { + activeTab: Tab; + onSelect: (tab: Tab) => void; + showStaffing: boolean; +}) { + const allTabs: Array<{ + value: Tab; + label: string; + Icon: React.ComponentType<{ className?: string }>; + }> = [ + { value: "reports", label: "Reports", Icon: ShieldAlert }, + { value: "feedback", label: "Feedback", Icon: MessageSquare }, + ...(showStaffing + ? [{ value: "staffing" as const, label: "Staffing", Icon: Users }] + : []), + ]; + return ( +
    + {allTabs.map(({ value, label, Icon }) => ( + + ))} +
    + ); +} + +// ── Panel root ──────────────────────────────────────────────────────────── + +export function AdminConsolePanel({ + canMutate, + origin, + pubkey, + role, + initialTab, + onSelfMutation, +}: { + /** + * Whether mutation controls should be enabled. `false` when the relay probe + * returned `disabled` — the admin API is accessible without credentials, so + * the operator can read but must not be offered write affordances that could + * accidentally mutate the relay without authentication. + */ + canMutate: boolean; + origin: string; + /** Active identity pubkey — all state is keyed on (pubkey, origin). */ + pubkey: string; + /** Principal role from probe — `"operator"` | `"moderator"` | undefined */ + role?: AdminPrincipalRole | null; + /** + * Called after a successful mutation that modified the current principal's + * own operator row (self-demotion or self-removal). The parent should + * re-probe the admin origin so the displayed role and visible tabs reflect + * the new server state. + */ + onSelfMutation?: () => void; + /** + * Override the initially active tab. Intended for unit tests that need to + * land on a specific tab without driving click events through MinimalDocument. + * Do not pass this prop in production code. + */ + initialTab?: Tab; +}) { + const isOperator = role === "operator"; + const [activeTab, setActiveTab] = useState(initialTab ?? "reports"); + // Increment whenever the (pubkey, origin) context changes to invalidate all + // in-flight useAsyncLoad effects via their effect-local `active` flags. + const generationRef = useRef(0); + const [generation, setGeneration] = useState(0); + + // biome-ignore lint/correctness/useExhaustiveDependencies: pubkey and origin are reactive props — effect fires when either changes to bump the generation fence + useEffect(() => { + generationRef.current += 1; + setGeneration(generationRef.current); + }, [pubkey, origin]); + + // Reset activeTab to the default when the current tab is no longer visible + // for the current role (e.g. operator→moderator while Staffing is selected). + // Guard is written against tab-visibility (the set TabBar would render for + // this role) rather than a hard-coded role string so it generalises to + // future tabs without requiring a companion role check here. + useEffect(() => { + const visibleTabs = new Set([ + "reports", + "feedback", + ...(isOperator ? (["staffing"] as Tab[]) : []), + ]); + if (!visibleTabs.has(activeTab)) { + setActiveTab("reports"); + } + }, [isOperator, activeTab]); + + return ( +
    + + {activeTab === "reports" && ( +
    + +
    + )} + {activeTab === "feedback" && ( + + )} + {activeTab === "staffing" && isOperator && ( + + )} +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx new file mode 100644 index 0000000000..c9226de307 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx @@ -0,0 +1,358 @@ +/** + * Shared helpers for the admin console panel sub-components. + * + * Exported from here to avoid duplication across AdminConsolePanel.tsx, + * AdminConsoleFeedbackTab.tsx, and AdminConsoleStaffingTab.tsx. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { AlertCircle, LoaderCircle } from "lucide-react"; +import { formatRelativeTime } from "../forum/lib/time"; + +// ── Generic async state ─────────────────────────────────────────────────── + +export type AsyncState = + | { status: "idle" } + | { status: "loading" } + | { status: "ok"; data: T } + | { status: "error"; message: string }; + +/** + * Async load hook with effect-local active-flag cancellation. + * + * Each effect invocation sets `active = true` and flips it to `false` in the + * cleanup function. Completions check `active` before calling setState, so a + * result that arrives after the deps changed (or the component unmounted) is + * silently discarded. + * + * `load` is stored in a ref so it is not a dependency of the effect — callers + * create it inline and `deps` + `generation` are the explicit trigger list. + */ +export function useAsyncLoad( + load: () => Promise, + deps: unknown[], + generation: number, +): AsyncState { + const [state, setState] = useState>({ status: "idle" }); + const loadRef = useRef(load); + loadRef.current = load; + + // biome-ignore lint/correctness/useExhaustiveDependencies: loadRef is a stable ref; deps and generation are the intentional trigger set + useEffect(() => { + let active = true; + setState({ status: "loading" }); + loadRef.current().then( + (data) => { + if (!active) return; + setState({ status: "ok", data }); + }, + (e: unknown) => { + if (!active) return; + setState({ + status: "error", + message: e instanceof Error ? e.message : String(e), + }); + }, + ); + return () => { + active = false; + }; + }, [...deps, generation]); + + return state; +} + +// ── Admin error message parsing ─────────────────────────────────────────── + +/** + * Extract a human-readable message from an admin mutation error. + * + * Native admin commands reject with `admin API error: {json}` where the JSON + * is the relay's error envelope (`{"error":{"code","message","requestId"}}`). + * This strips the prefix and returns the envelope's `message` field so the UI + * can surface "action kick requires the report to have an associated channel" + * instead of the raw JSON. Falls back to the raw string when the payload is + * not the expected shape (network errors, non-JSON bodies). + */ +export function adminErrorMessage(e: unknown): string { + const raw = e instanceof Error ? e.message : String(e); + const jsonStart = raw.indexOf("{"); + if (jsonStart === -1) return raw; + try { + const parsed = JSON.parse(raw.slice(jsonStart)); + const message = parsed?.error?.message; + return typeof message === "string" && message.length > 0 ? message : raw; + } catch { + return raw; + } +} + +/** + * Extract the relay's HTTP status from a rejected admin mutation, or `null`. + * + * Native mutation commands reject with a typed `AdminMutationError` + * (`{message, relayStatus, bodyComplete}`); the Tauri bridge surfaces it as + * `TauriInvokeError` whose `payload` is that object. `relayStatus` is a number + * only when the relay actually answered — `null`/absent for a transport or + * pre-send failure where no relay verdict exists. + */ +export function adminMutationRelayStatus(e: unknown): number | null { + if (e && typeof e === "object" && "payload" in e) { + const payload = (e as { payload: unknown }).payload; + if (payload && typeof payload === "object" && "relayStatus" in payload) { + const status = (payload as { relayStatus: unknown }).relayStatus; + if (typeof status === "number") return status; + } + } + return null; +} + +/** + * Whether the relay's full response body was read — an authoritative verdict. + * + * `AdminMutationError.bodyComplete` is `true` only when the relay answered AND + * its whole body was received. A status that arrives but whose body is lost + * mid-stream (or rejected over the size cap) is `false`: the outcome is + * unknown. Absent/non-boolean payloads (bare-string errors, non-typed + * rejections) read `false`, which is fail-safe — an unknown outcome preserves + * the idempotency key. + */ +export function adminMutationBodyComplete(e: unknown): boolean { + if (e && typeof e === "object" && "payload" in e) { + const payload = (e as { payload: unknown }).payload; + if (payload && typeof payload === "object" && "bodyComplete" in payload) { + const complete = (payload as { bodyComplete: unknown }).bodyComplete; + if (typeof complete === "boolean") return complete; + } + } + return false; +} + +/** + * Whether a failed mutation must reuse its idempotency `requestId` on retry. + * + * The id is preserved UNLESS the relay definitively rejected the request before + * committing — a non-409 4xx whose full body was read. Those (bad action, + * unauthorized, not found) refuse the input pre-commit, so a corrected + * resubmission is a genuinely new command and a fresh id is safe. + * + * Everything else preserves the id so the relay can dedupe against a commit + * that may have landed: + * - 409 — an idempotency claim or in-progress action already exists; + * - 5xx — the relay may have committed before failing; + * - a lost or truncated response body (status arrived, `bodyComplete` false — + * outcome unknown), including a truncated 4xx; + * - a transport or pre-send failure with no relay answer (`relayStatus` null). + * + * Status alone is insufficient: a truncated 4xx carries a definitive-looking + * status without an authoritative body, so the `bodyComplete` bit gates the + * reset. This replaces string-matching `"409"`/`"processing"` on the message, + * which missed the native layer's transport errors and cleared the id on + * exactly the ambiguous lost-response failures where reuse is required. + */ +export function preserveRequestIdOnError(e: unknown): boolean { + const status = adminMutationRelayStatus(e); + if (status === null) return true; + if (status === 409) return true; + if (status < 400 || status >= 500) return true; + // A non-409 4xx resets only when the relay's full body confirmed the verdict. + return !adminMutationBodyComplete(e); +} + +// ── Shared UI helpers ───────────────────────────────────────────────────── + +export function LoadingSpinner() { + return ( +
    + + Loading… +
    + ); +} + +export function ErrorMessage({ message }: { message: string }) { + return ( +
    + + {message} +
    + ); +} + +// ── Timestamp formatter ─────────────────────────────────────────────────── + +export function formatTimestamp(raw: string | null | undefined): string { + if (!raw) return "—"; + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return raw; + const absolute = date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + const rel = formatRelativeTime(Math.floor(date.getTime() / 1000)); + // Render relative label with the absolute value inline in parentheses. + return `${rel} (${absolute})`; +} + +// ── Structured detail row ───────────────────────────────────────────────── + +export function DetailRow({ + label, + value, + mono, +}: { + label: string; + value: string | null | undefined; + mono?: boolean; +}) { + return ( +
    + {label} + + {value ?? "—"} + +
    + ); +} + +// ── Attachment meta ─────────────────────────────────────────────────────── + +export type AttachmentMeta = { + /** Lowercase 64-hex SHA-256 as stored/returned by the relay. */ + sha256: string; + /** MIME type from the `m` imeta field. */ + mime: string; + /** Byte size from the `size` imeta field. */ + size: number; +}; + +/** + * Parse imeta attachment metadata from the relay's `tags: string[][]` wire + * format. Matches the reference SPA implementation in `admin-web/src/App.tsx`. + * + * Each `imeta` tag looks like: + * `["imeta", "url https://...", "m image/png", "x ", "size 12345"]` + * Each entry after `"imeta"` is a singleton `"key value"` string. + * + * Rejected: missing x/m/size, non-lowercase-hex x, non-positive size. + */ +export function parseImetaAttachments(tags: unknown): AttachmentMeta[] { + if (!Array.isArray(tags)) return []; + const result: AttachmentMeta[] = []; + for (const tag of tags) { + if (!Array.isArray(tag) || tag[0] !== "imeta") continue; + const values = new Map(); + for (const entry of (tag as string[]).slice(1)) { + const sep = typeof entry === "string" ? entry.indexOf(" ") : -1; + if (sep > 0) { + values.set(entry.slice(0, sep), entry.slice(sep + 1)); + } + } + const sha256 = values.get("x") ?? ""; + const mime = values.get("m") ?? ""; + const rawSize = values.get("size") ?? ""; + const size = Number(rawSize); + // Require exactly 64 lowercase hex chars for the hash (relay stores lowercase; + // uppercase returns 404). Require a non-empty MIME type and a positive size. + if ( + sha256.length !== 64 || + !/^[0-9a-f]{64}$/.test(sha256) || + !mime || + !Number.isFinite(size) || + size <= 0 + ) { + continue; + } + result.push({ sha256, mime, size }); + } + return result; +} + +// ── Community grouping ───────────────────────────────────────────────────── + +/** A run of rows that share one community, tagged with its display host. */ +export type CommunityGroup = { + /** Stable community identifier — used as the React key. */ + communityId: string; + /** Human-facing host label rendered as the group heading. */ + communityHost: string; + items: T[]; +}; + +/** React key + heading for rows whose source community has been purged. */ +const SEVERED_COMMUNITY_KEY = "__severed__"; +const SEVERED_COMMUNITY_HOST = "(source community removed)"; + +/** + * Group deployment-wide rows by community, preserving each community's + * first-seen order and the server's row order within it. + * + * The admin API returns reports and feedback across every community on the + * deployment; operators triage per community, so rows are bucketed by + * `communityId` (stable) and labelled by `communityHost` (display). A blank + * host falls back to the id so a group is never headed by an empty string. + * + * Feedback whose source community was purged carries a `null` `communityId` + * (tenant provenance severed, the row retained as operator evidence). Those + * rows bucket into a single "source community removed" group so a null key + * never collapses distinct rows or heads a group with an empty string. + */ +export function groupByCommunity< + T extends { communityId: string | null; communityHost: string | null }, +>(items: T[]): CommunityGroup[] { + const groups: CommunityGroup[] = []; + const byId = new Map>(); + for (const item of items) { + const key = item.communityId ?? SEVERED_COMMUNITY_KEY; + let group = byId.get(key); + if (!group) { + group = { + communityId: key, + communityHost: + item.communityId == null + ? SEVERED_COMMUNITY_HOST + : item.communityHost || item.communityId, + items: [], + }; + byId.set(key, group); + groups.push(group); + } + group.items.push(item); + } + return groups; +} + +/** + * Render community-grouped rows under per-community headings. + * + * A single community collapses to a flat list (no redundant heading); two or + * more render a labelled section each. `renderItem` produces the row for one + * entry — the caller owns row markup so navigation/testids are unchanged. + */ +export function CommunityGroupedList< + T extends { communityId: string | null; communityHost: string | null }, +>({ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }) { + const groups = useMemo(() => groupByCommunity(items), [items]); + if (groups.length <= 1) { + return
      {items.map(renderItem)}
    ; + } + return ( +
    + {groups.map((group) => ( +
    +

    + {group.communityHost} +

    +
      {group.items.map(renderItem)}
    +
    + ))} +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleReportsTab.tsx b/desktop/src/features/admin-console/AdminConsoleReportsTab.tsx new file mode 100644 index 0000000000..d2de7c4e87 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleReportsTab.tsx @@ -0,0 +1,975 @@ +/** + * Reports tab — deployment-wide moderation reports for the admin console. + * + * Extracted from AdminConsolePanel.tsx. Exposes only `ReportsTab`; every other + * component and helper here is private to this file. The panel renders + * `ReportsTab` inside its `reports` tab. + */ + +import { useRef, useState } from "react"; +import { ChevronLeft, Copy, LoaderCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; +import { cn } from "@/shared/lib/cn"; +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { PubKey } from "@/shared/ui/PubKey"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { UserProfileSummary } from "@/shared/api/types"; +import { + getAdminReport, + listAdminReports, + cancelAdminReport, + reopenAdminReport, + resolveAdminReport, + type AdminReportAction, + type AdminReportDetailDto, + type AdminReportDto, + type AdminReportResolution, +} from "./api"; +import { + DetailRow, + ErrorMessage, + LoadingSpinner, + CommunityGroupedList, + formatTimestamp, + useAsyncLoad, + adminErrorMessage, + preserveRequestIdOnError, +} from "./AdminConsolePanelHelpers"; + +// ── Display-name helper (mirrors AdminConsoleStaffingTab) ───────────────── + +/** Pubkey of the person a report is about: the member itself, or the reported + * event's author. `null` for blob targets and events the relay no longer has. */ +function reportedPersonPubkey(report: AdminReportDto): string | null { + switch (report.targetKind.toLowerCase()) { + case "pubkey": + return report.target; + case "event": + return report.targetAuthorPubkey ?? null; + default: + return null; + } +} + +function formatReportTarget( + report: AdminReportDto, + profiles?: Record, +): string { + const person = reportedPersonPubkey(report); + if (report.targetKind.toLowerCase() === "event") { + return person + ? `message by ${formatDisplayName(person, profiles?.[person])}` + : `message ${truncatePubkey(report.target)}`; + } + return person + ? formatDisplayName(person, profiles?.[person]) + : truncatePubkey(report.target); +} + +function formatDisplayName( + pubkey: string, + profile?: UserProfileSummary | null, +): string { + const trimmed = profile?.displayName?.trim(); + if (trimmed && !trimmed.toLowerCase().startsWith("npub1")) { + return trimmed; + } + return truncatePubkey(pubkey); +} + +// ── Status variant helper ───────────────────────────────────────────────── + +function statusVariant( + status: string, +): "default" | "secondary" | "destructive" | "outline" { + switch (status) { + case "open": + return "default"; + case "resolved": + return "secondary"; + case "dismissed": + return "outline"; + case "escalated": + return "secondary"; + case "processing": + return "secondary"; + case "pending": + return "secondary"; + case "enforcing": + return "secondary"; + case "succeeded": + return "secondary"; + case "failed": + return "destructive"; + case "cancelled": + return "outline"; + default: + return "outline"; + } +} + +// ── Action matrix helpers ───────────────────────────────────────────────── + +/** + * Return the allowed actions for a given target kind per the v4 frozen matrix. + * event: delete | kick | ban | timeout | dismiss | escalate + * pubkey: ban | timeout | dismiss | escalate + * blob: dismiss | escalate + */ +function allowedActionsForTargetKind(targetKind: string): AdminReportAction[] { + switch (targetKind.toLowerCase()) { + case "event": + return ["delete", "kick", "ban", "timeout", "dismiss", "escalate"]; + case "pubkey": + return ["ban", "timeout", "dismiss", "escalate"]; + case "blob": + return ["dismiss", "escalate"]; + default: + return ["dismiss", "escalate"]; + } +} + +/** Label for each action. */ +function actionLabel(action: AdminReportAction): string { + switch (action) { + case "delete": + return "Delete"; + case "kick": + return "Kick"; + case "ban": + return "Ban"; + case "timeout": + return "Timeout"; + case "dismiss": + return "Dismiss"; + case "escalate": + return "Escalate"; + } +} + +/** Variant for each action button. */ +function actionVariant( + action: AdminReportAction, +): "destructive" | "outline" | "secondary" { + switch (action) { + case "delete": + case "ban": + return "destructive"; + case "kick": + case "timeout": + return "outline"; + default: + return "secondary"; + } +} + +// ── Kick-on-open-channel friendly error translation ─────────────────────── + +/** + * Translate raw relay enforcement error messages into operator-friendly copy. + * + * The relay records enforcement errors verbatim (anyhow error strings); they + * are informative but use implementation vocabulary. Map the known kick + * AlreadyGone message to a clear operator-facing action recommendation. + */ +function friendlyEnforcementError(message: string): string { + if (message.includes("kick target was already absent")) { + return "No channel membership to remove — use Timeout or Ban for open channels."; + } + return message; +} + +// ── Enforcement state block ─────────────────────────────────────────────── + +/** + * Inline enforcement-state block shown on `processing` reports and after a + * failed enforcement action. Shows the action record's state and offers cancel + * on a `failed` action. + * + * A `failed` action is always pre-mutation (the relay only records `failed` + * before the enforcement side effect lands), so it is always cancellable. + * Cancel is the only recovery path: it returns the report to `open` for a + * fresh resolution. There is no client-side "retry" — composing cancel + a new + * resolve would imply an atomicity the relay does not provide, leaving a window + * where the report is open with no explanation if the second call is lost. + * + * `pending`/`enforcing` actions are NOT cancellable over HTTP — the relay's + * recovery worker owns their convergence — so no button is offered there. + * A rejected cancel (409) is authoritative: the action already advanced or + * someone else cancelled it, so reload detail rather than retrying. + */ +function EnforcementStateBlock({ + activeAction, + canMutate, + origin, + reportId, + onActionComplete, +}: { + activeAction: NonNullable; + /** Whether mutation controls are enabled. `false` in disabled-auth mode. */ + canMutate: boolean; + origin: string; + reportId: string; + onActionComplete: () => void; +}) { + const [isWorking, setIsWorking] = useState(false); + + const actionStatus = activeAction.status; + + // User-facing copy for each action state. + const stateLabel: Record = { + pending: "Enforcement pending…", + enforcing: "Enforcing…", + succeeded: "Enforcement succeeded", + failed: "Enforcement failed", + cancelled: "Enforcement cancelled", + }; + + const handleCancel = async () => { + setIsWorking(true); + try { + // Fence the cancel to the exact failed action the operator observed. On + // success the report returns to `open`; the detail reload then serves + // `activeAction: null` and re-exposes the resolve form for a fresh attempt. + await cancelAdminReport(origin, reportId, { + actionId: activeAction.id, + }); + toast.success("Enforcement cancelled — report reopened"); + onActionComplete(); + } catch (e) { + // A 409 means the action is no longer cancellable (already cancelled, + // superseded, or past the mutation point). Reload detail rather than + // retry — the toast is informational, the reload shows current state. + toast.error(`Cancel rejected: ${adminErrorMessage(e)}`); + onActionComplete(); + } finally { + setIsWorking(false); + } + }; + + return ( +
    +
    + {(actionStatus === "pending" || actionStatus === "enforcing") && ( + + )} + + {stateLabel[actionStatus] ?? actionStatus} + + + {activeAction.action} + +
    + {activeAction.errorMessage && ( +

    + {friendlyEnforcementError(activeAction.errorMessage)} +

    + )} + {actionStatus === "failed" && canMutate && ( + + )} +
    + ); +} + +// ── Resolve report form ─────────────────────────────────────────────────── + +/** + * Disclose where the reason travels for each action family so operators + * understand the privacy and public-notice implications before submitting. + * + * - delete: verbatim to the affected user AND publicly in the room tombstone. + * - kick / ban / timeout: verbatim to the affected user only. + * - dismiss / escalate: verbatim to the reporter (no affected-user notice). + * + * Source: relay_admin_actions.rs and admin_outbox_worker.rs notice paths. + */ +function reasonAudienceCopy(action: AdminReportAction): string { + switch (action) { + case "delete": + return "Sent verbatim to the affected user and posted publicly in the room."; + case "kick": + case "ban": + case "timeout": + return "Sent verbatim to the affected user."; + case "dismiss": + case "escalate": + return "Sent verbatim to the reporter."; + } +} + +/** + * Derive a human-readable action label from an `AdminReportResolution`. + * + * `activeAction.action` is authoritative for enforcement actions. For + * decision-only resolutions (dismiss/escalate), `activeAction` is null; + * the terminal `status` encodes the outcome. Never falls back to form state. + */ +function resolutionLabel(resolution: AdminReportResolution): string { + if (resolution.activeAction?.action) { + return actionLabel(resolution.activeAction.action); + } + switch (resolution.status) { + case "dismissed": + return actionLabel("dismiss"); + case "escalated": + return actionLabel("escalate"); + default: + return resolution.status; + } +} + +/** + * Frozen submit payload — the whole command sent on first attempt. Retained + * across ambiguous failures for byte-for-byte retry; cleared only on a + * definitive pre-commit rejection (non-409 4xx with full body). + */ +type FrozenPayload = { + requestId: string; + action: AdminReportAction; + reason: string | undefined; + expirationSecs: number | undefined; +}; + +/** + * Resolution form — shown on open reports (not `processing`). Presents the + * action matrix for the report's target_kind, collects optional reason and + * (for timeout) expiration_secs, then calls the resolve endpoint. + * + * The form generates a `requestId` per submission attempt. On retry after a + * lost response, the caller should reuse the same `requestId` — this is + * handled by the retry path in `EnforcementStateBlock`. + */ +function ResolveReportForm({ + report, + origin, + onResolved, +}: { + report: AdminReportDto; + origin: string; + onResolved: () => void; +}) { + const [selectedAction, setSelectedAction] = + useState(null); + const [reason, setReason] = useState(""); + const [expirationSecs, setExpirationSecs] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + // Frozen whole-payload snapshot. Set on first submit; retained across + // ambiguous failures; cleared on a definitive pre-commit rejection. + const frozenRef = useRef(null); + + // Locked between attempts: snapshot held but not actively submitting. + // Prevents edits that would diverge from the frozen idempotency payload. + const isLocked = frozenRef.current !== null && !isSubmitting; + + // Kick removes the target from the report's associated channel, so the + // relay rejects it (400 invalid_action_for_target) when the report carries + // no channel. Suppress it client-side rather than offer a guaranteed failure. + const allowedActions = allowedActionsForTargetKind( + report.targetKind ?? "", + ).filter((a) => a !== "kick" || report.channelId != null); + + const handleSubmit = async () => { + if (!selectedAction) return; + setIsSubmitting(true); + + // On first attempt freeze the whole payload; on retry reuse it byte-for-byte. + if (!frozenRef.current) { + frozenRef.current = { + requestId: crypto.randomUUID(), + action: selectedAction, + reason: reason.trim() || undefined, + expirationSecs: + selectedAction === "timeout" && expirationSecs + ? Number(expirationSecs) + : undefined, + }; + } + const payload = frozenRef.current; + + try { + const resolution = await resolveAdminReport(origin, report.id, { + action: payload.action, + requestId: payload.requestId, + expirationSecs: payload.expirationSecs, + reason: payload.reason, + }); + // Derive toast from the authoritative relay response, not mutable form + // state — relay idempotency executes the first command even on retry. + toast.success(`Report resolved: ${resolutionLabel(resolution)}`); + onResolved(); + } catch (e) { + // Preserve the frozen payload whenever the outcome is ambiguous (409, + // 5xx, a lost response, or a transport failure with no relay answer) + // so a retry reuses the same idempotency key and the relay dedupes. + // Discard only on a definitive pre-commit rejection (a non-409 4xx with + // full body), where a corrected resubmission is a genuinely new command. + if (!preserveRequestIdOnError(e)) { + frozenRef.current = null; + } + toast.error(adminErrorMessage(e)); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
    +

    + Resolve report +

    +
    + {allowedActions.map((action) => ( + + ))} +
    + + {selectedAction === "timeout" && ( +
    + + setExpirationSecs(e.target.value)} + placeholder="e.g. 3600" + type="number" + value={expirationSecs} + /> +
    + )} + +
    + setReason(e.target.value)} + placeholder="Reason (optional)" + type="text" + value={reason} + /> + {selectedAction && ( +

    + {reasonAudienceCopy(selectedAction)} +

    + )} +
    + + {selectedAction && ( + + )} +
    + ); +} + +// ── Reopen report form ──────────────────────────────────────────────────── + +/** + * Reopen form — shown on terminal reports (`resolved` | `dismissed` | + * `escalated`). Moves the report back to `open` for re-triage. + * + * Reopen is re-triage only: it does NOT reverse any enforcement already taken + * (no un-ban, no un-timeout, no message restore). The copy states this + * explicitly, and more emphatically when the report carries an `actionId` + * (an enforcement action was applied while it was resolved). + * + * A `requestId` is generated per attempt and reused on retry so a lost + * response is idempotent, mirroring the resolve flow. A 409 (report not + * reopenable — e.g. it moved to `processing`) preserves the `requestId`. + */ +function ReopenReportForm({ + report, + origin, + onReopened, +}: { + report: AdminReportDto; + origin: string; + onReopened: () => void; +}) { + const [reason, setReason] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + // Stable requestId per attempt; reused on retry after a lost response. + const requestIdRef = useRef(null); + + // An enforcement action was applied while this report was resolved. + const wasEnforced = report.actionId != null; + + const handleSubmit = async () => { + setIsSubmitting(true); + + if (!requestIdRef.current) { + requestIdRef.current = crypto.randomUUID(); + } + + try { + await reopenAdminReport(origin, report.id, { + requestId: requestIdRef.current, + reason: reason.trim() || undefined, + }); + toast.success("Report reopened"); + onReopened(); + } catch (e) { + // Preserve the requestId on an ambiguous outcome (409, 5xx, lost + // response, or a transport failure with no relay answer) so a retry + // reuses the same idempotency key; reset only on a definitive pre-commit + // rejection (a non-409 4xx). + if (!preserveRequestIdOnError(e)) { + requestIdRef.current = null; + } + toast.error(adminErrorMessage(e)); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
    +
    +

    + Reopen report +

    +

    + Moves this report back to the open queue for re-triage.{" "} + {wasEnforced + ? "The enforcement action already taken is not reversed — reopening does not un-ban, un-timeout, or restore a deleted message." + : "Reopening does not reverse any enforcement action."} +

    +
    + + setReason(e.target.value)} + placeholder="Reason (optional)" + type="text" + value={reason} + /> + + +
    + ); +} + +// ── Reports tab ─────────────────────────────────────────────────────────── + +export function ReportsTab({ + canMutate, + origin, + pubkey, + generation, +}: { + canMutate: boolean; + origin: string; + pubkey: string; + generation: number; +}) { + const [selectedId, setSelectedId] = useState(null); + // List refresh fence: bumped whenever a mutation completes in the detail + // view, so returning to the list shows fresh status without a tab switch. + const [listGen, setListGen] = useState(0); + + const listState = useAsyncLoad( + // Request the full workflow queue: open, processing, resolved, dismissed, + // escalated. The relay's omitted-scope default is escalated-only (the + // platform-safety backstop); scope=all gives this console access to the + // states its own resolve/cancel/reopen controls act on. + () => listAdminReports(origin, { scope: "all" }), + [origin, pubkey], + generation + listGen, + ); + + // Batch-resolve display names for the people on each row (reporter plus the + // reported person) so list rows show names rather than truncated hex. Event + // IDs and blob hashes are identifiers, never profile keys. + const listedPubkeys = + listState.status === "ok" + ? [ + ...new Set( + listState.data.flatMap((r) => + [r.reporterPubkey, reportedPersonPubkey(r)].filter( + (k): k is string => typeof k === "string" && k.length === 64, + ), + ), + ), + ] + : []; + const profilesQuery = useUsersBatchQuery(listedPubkeys, { + enabled: listedPubkeys.length > 0, + }); + + if (selectedId) { + return ( + setSelectedId(null)} + onMutated={() => setListGen((g) => g + 1)} + /> + ); + } + + if (listState.status === "loading") { + return ; + } + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const reports = listState.data; + if (!Array.isArray(reports) || reports.length === 0) { + return

    No reports found.

    ; + } + + return ( + { + const id = report.id; + const summary = report.reportType || "Report"; + const status = report.status; + const isProcessing = status === "processing"; + // Show a brief reporter → target snippet so rows are distinguishable. + const snippet = [ + report.reporterPubkey + ? `reporter: ${formatDisplayName(report.reporterPubkey, profilesQuery.data?.profiles[report.reporterPubkey])}` + : null, + report.target + ? `target: ${formatReportTarget(report, profilesQuery.data?.profiles)}` + : null, + ] + .filter(Boolean) + .join(" · "); + return ( +
  • + {/* Processing rows stay navigable: the enforcement state (progress, + retry, cancel) lives inside the detail view, so disabling the row + would hide exactly the controls an operator needs while an action + is pending. Detail suppresses only the resolve form for a + non-open report. */} + +
  • + ); + }} + /> + ); +} + +function ReportFields({ data }: { data: AdminReportDetailDto }) { + const status = data.status ?? ""; + // Valid 64-char hex pubkeys get the PubKey widget (compact npub + hover + // popover). Shorter/invalid strings (test fixtures, placeholder values) + // fall back to the plain mono DetailRow so existing behaviour is preserved. + const isHex64 = (s: string | undefined | null): s is string => + typeof s === "string" && /^[0-9a-f]{64}$/.test(s.toLowerCase()); + + return ( +
    +
    + {status && {status}} + {data.reportType && {data.reportType}} +
    + + + + + {/* Reporter uses PubKey widget for valid keys; falls back to mono text. */} + {isHex64(data.reporterPubkey) ? ( +
    + Reporter + +
    + ) : ( + + )} + + {/* Pubkey targets get the PubKey widget; event IDs are not keys, so they + render as raw hex with a copy control. */} + {isHex64(data.target) && data.targetKind?.toLowerCase() === "pubkey" ? ( +
    + Target + +
    + ) : data.targetKind?.toLowerCase() === "event" ? ( +
    + Target + + {data.target} + + +
    + ) : ( + + )} + + + + + + + {data.message != null && ( +
    +

    + Reported message + {data.message.deletedAt != null && ( + (deleted) + )} +

    + {isHex64(data.message.authorPubkey) ? ( +
    + + Author + + +
    + ) : ( + + )} + + +
    + )} +
    + ); +} + +function ReportDetail({ + canMutate, + origin, + pubkey, + generation, + reportId, + onBack, + onMutated, +}: { + canMutate: boolean; + origin: string; + pubkey: string; + generation: number; + reportId: string; + onBack: () => void; + /** Called after any mutation completes so the parent list can refetch. */ + onMutated: () => void; +}) { + // Resolution generation: bump to reload detail after an action completes. + const [resolveGen, setResolveGen] = useState(0); + + // Reload the detail AND signal the parent list on every completed mutation, + // so back-nav shows fresh status without the tab-switch workaround. + const handleMutated = () => { + setResolveGen((g) => g + 1); + onMutated(); + }; + + const detailState = useAsyncLoad( + () => getAdminReport(origin, reportId), + [origin, pubkey, reportId], + generation + resolveGen, + ); + + const data = detailState.status === "ok" ? detailState.data : null; + const isOpen = data?.status === "open"; + const isReopenable = + data?.status === "resolved" || + data?.status === "dismissed" || + data?.status === "escalated"; + const activeAction = data?.activeAction ?? null; + + return ( +
    + + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( + <> + + {/* Enforcement state / history. The detail LATERAL returns an action + whenever one governs the report: a live action (pending/enforcing) + or a cancellable failed action while processing, or a succeeded + action as executed-enforcement history on a terminal or reopened + report (honest history — a later dismissal/reopen does not + un-happen the ban that ran). Cancel is offered only on failed, + inside the block. A cancelled action never reaches this read. */} + {activeAction && ( + + )} + {/* Resolve form: shown for open reports. A reopened-after-enforcement + report is open yet carries a succeeded activeAction (history); + the form must still show so the operator can re-triage — the + enforcement block above renders that history alongside it. Only a + live/failed action keeps the report `processing` (not open), so + `isOpen` alone never surfaces the form on an in-flight action. */} + {isOpen && canMutate && ( + + )} + {/* Reopen form: only for terminal (resolved/dismissed/escalated) reports */} + {isReopenable && canMutate && ( + + )} + + )} +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx new file mode 100644 index 0000000000..15a1a6b3f2 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx @@ -0,0 +1,578 @@ +/** + * Settings card for the desktop admin console. + * + * Lets an operator enter the admin console URL (the value of `BUZZ_ADMIN_HOST` + * on their relay), then probes it to determine auth mode and whether the + * current app identity is on the allowlist. + * + * Identity boundary: the stateful body is rendered as + * `` so that React + * synchronously unmounts A's entire state tree before B is rendered. Logout + * (pubkeyHex → empty string) renders nothing, so A's probe state, saved + * origin, and panel are torn down at the render level — not in a passive effect. + * + * Renders the full admin panel when probe state is `nip98Authorized` or + * `disabled`. The `disabled` state means the relay does not require or + * validate a credential on the admin API — the desktop still signs outgoing + * requests, but the relay accepts them unconditionally. The panel works the + * same way in both states. + */ + +import { useEffect, useRef, useState } from "react"; +import { + AlertCircle, + Check, + CheckCircle2, + ChevronRight, + Copy, + Info, + LoaderCircle, +} from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { cn } from "@/shared/lib/cn"; +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { + getAdminOrigin, + probeAdminOrigin, + setAdminOrigin, + discoverAdminOrigin, + type AdminPrincipalRole, + type AdminPrincipalSource, + type AdminProbeState, +} from "./api"; +import { AdminConsolePanel } from "./AdminConsolePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; + +// ── Probe state → UI copy ───────────────────────────────────────────────── + +// ── DeniedBadge — copy-icon button for the pubkey ───────────────────────── + +function DeniedBadge({ pubkeyHex }: { pubkeyHex: string }) { + const [copied, setCopied] = useState(false); + const resetTimer = useRef(undefined); + useEffect(() => () => window.clearTimeout(resetTimer.current), []); + + return ( + + + + Access denied + + + Your pubkey is not authorized as an operator on this relay. Ask a relay + operator to add: + + + + {pubkeyHex} + + + + + Other possible causes: clock skew > 60 s or a relay config mismatch. + + + ); +} + +type ProbeUiState = + | { kind: "idle" } + | { kind: "probing" } + | { + kind: "authorized"; + origin: string; + role?: AdminPrincipalRole | null; + source?: AdminPrincipalSource | null; + } + | { kind: "denied"; pubkeyHex: string } + | { kind: "disabled"; origin: string } + | { kind: "notAdminApi" } + | { kind: "networkOrIntercepted" } + | { kind: "error"; message: string }; + +function ProbeStatusBadge({ uiState }: { uiState: ProbeUiState }) { + if (uiState.kind === "idle") return null; + if (uiState.kind === "probing") { + return ( + + + Probing… + + ); + } + if (uiState.kind === "authorized") { + const roleLabel = uiState.role ?? "operator"; + return ( + + + Connected as {roleLabel} + + ); + } + if (uiState.kind === "denied") { + return ; + } + if (uiState.kind === "disabled") { + return ( + + + Auth is disabled on this relay. The admin console is accessible without + a credential. + + ); + } + if (uiState.kind === "notAdminApi") { + return ( + + + No admin API found at this origin. Check the URL matches{" "} + BUZZ_ADMIN_HOST. + + ); + } + if (uiState.kind === "networkOrIntercepted") { + return ( + + + Could not reach the relay. Check: network, TLS certificate, DNS, or + whether a VPN/SSO layer (e.g. Cloudflare Access) intercepts this host. + + ); + } + // error + return ( + + + {uiState.message} + + ); +} + +function probeStateToUiState( + result: { + state: AdminProbeState; + role?: AdminPrincipalRole | null; + source?: AdminPrincipalSource | null; + }, + origin: string, + pubkeyHex: string, +): ProbeUiState { + switch (result.state) { + case "nip98Authorized": + return { + kind: "authorized", + origin, + role: result.role, + source: result.source, + }; + case "nip98Denied": + return { kind: "denied", pubkeyHex }; + case "disabled": + return { kind: "disabled", origin }; + case "notAdminApi": + return { kind: "notAdminApi" }; + case "networkOrIntercepted": + return { kind: "networkOrIntercepted" }; + } +} + +// ── Main card ───────────────────────────────────────────────────────────── + +export function AdminConsoleSettingsCard() { + const { data: identity } = useIdentityQuery(); + const pubkeyHex = identity?.pubkey ?? ""; + + return ( +
    + + {pubkeyHex ? ( + + ) : null} +
    + ); +} + +// ── Stateful session — keyed by pubkeyHex ───────────────────────────────── +// +// React's `key` prop causes the parent to unmount this component entirely when +// the pubkey changes. That means: +// - A→B switch: A's entire state tree (originInput, savedOrigin, probeUiState, +// isSaving, in-flight probes) is destroyed synchronously before B mounts. +// - Logout (pubkeyHex → ""): the parent renders `null`, so A's state is gone +// before any new render begins. +// +// This eliminates the passive-effect reset race where the parent rendered with +// B's pubkey and A's stale origin/authorized state for one render cycle. + +function AdminConsoleSettingsSession({ pubkeyHex }: { pubkeyHex: string }) { + const [originInput, setOriginInput] = useState(""); + const [savedOrigin, setSavedOrigin] = useState(null); + const [probeUiState, setProbeUiState] = useState({ + kind: "idle", + }); + const [isSaving, setIsSaving] = useState(false); + // Whether the Advanced (origin entry) disclosure is open. Auto-opens when a + // relay-advertised origin pre-fills the input so the operator sees the value + // awaiting their explicit Save. + const [advancedOpen, setAdvancedOpen] = useState(false); + + // In-flight probe abort controller. Does not cancel the Tauri native request + // (not cancellable), but prevents a stale probe result from updating UI state. + const probeAbortRef = useRef(null); + + // Save/probe context token: captures (pubkey, origin) at the time a save + // starts. handleSave checks this before committing any state so a delayed + // save cannot repopulate the wrong session. + // + // On unmount, the cleanup effect below sets sessionTokenRef.current = null. + // Every handleSave continuation leg checks `sessionTokenRef.current !== token` + // (null !== token object) → returns early on all paths. This is StrictMode-safe: + // StrictMode's simulated cleanup fires the null assignment, then the re-mount + // re-arms the ref when the next handleSave sets `sessionTokenRef.current = token`. + type SessionToken = { pubkey: string; origin: string }; + const sessionTokenRef = useRef(null); + + // Mirrors savedOrigin state as a ref so closures can read the current value + // without stale capture. Mutated synchronously alongside setSavedOrigin via + // the setSavedOriginBoth helper below; never set directly. + const savedOriginRef = useRef(null); + + // Use this in place of bare setSavedOrigin to keep the ref in sync. + function setSavedOriginBoth(v: string | null) { + savedOriginRef.current = v; + setSavedOrigin(v); + } + + // Synchronously abort any active probe and reset probe UI state. + // Call before starting a new probe or on any input change. + function abortAndResetProbe() { + probeAbortRef.current?.abort(); + probeAbortRef.current = null; + setProbeUiState({ kind: "idle" }); + } + + // Null both sessionTokenRef and savedOriginRef on unmount. + // + // sessionTokenRef: A's deferred handleSave continuation fails the token check + // on all legs after A's component is torn down. + // + // savedOriginRef: A's deferred self-mutation completion (handleConfirmRemove in + // StaffingTab) calls onSelfMutation?.() which closes over savedOriginRef. If + // the ref still holds A's origin after teardown, the fence + // `savedOriginRef.current === originAtRender` is A === A → true → runProbe(A) + // fires, signing a NIP-98 request with the now-active identity's keys. Nulling + // the ref makes the fence false (null !== A) regardless of which origin was + // active at session mount, closing the post-teardown signing leak. + // + // StrictMode safety: the simulated cleanup nulls both refs before the second + // mount's load effect resolves. setSavedOriginBoth is called again by that + // effect, re-arming the ref for the live session. + useEffect(() => { + return () => { + sessionTokenRef.current = null; + savedOriginRef.current = null; + }; + }, []); + + // Load saved origin on mount (runs once per session because the component + // is keyed by pubkeyHex — re-mount = new pubkey). When nothing is saved, + // attempt NIP-11 auto-discovery of the admin origin from the connected + // relay. A discovered origin is auto-saved and probed without requiring an + // explicit Save ONLY when it is same-host (its admin_api host matches the + // connected relay's host): that relay is a trusted source and + // AdminOrigin::parse validates the value on the Rust side before it is + // stored or signed against. A cross-host advertisement is pre-fill only — we + // never auto-save or auto-probe it, because probing signs a NIP-98 header + // with the operator's key and a malicious relay must not coax an unconsented + // signature for an origin it does not own (the operator reviews the pre-filled + // value under Advanced and Saves explicitly if they trust it). The operator + // only needs to interact with the Advanced disclosure to change or clear the + // origin. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-once effect; identity boundary is the key prop on this component — it unmounts/remounts on pubkey change, so [] is correct. + useEffect(() => { + let active = true; + void (async () => { + try { + const saved = await getAdminOrigin(pubkeyHex); + if (!active) return; + if (saved) { + // A persisted origin (manual fallback or previously auto-saved + // discovery) takes precedence — probe it immediately. + setSavedOriginBoth(saved); + setOriginInput(saved); + runProbe(saved); + return; + } + // No saved origin: auto-discover from the relay's NIP-11 `admin_api`. + // Best-effort — a relay error, an absent field, or an advertised value + // that fails validation falls back to manual entry, never an error. + let discovered: { origin: string; sameHost: boolean } | null = null; + try { + discovered = await discoverAdminOrigin(); + } catch { + discovered = null; + } + if (!active) return; + if (discovered) { + if (!discovered.sameHost) { + // Cross-host advertisement: the relay points the admin_api at a + // host it does not own. Never auto-save or auto-probe it — probing + // would sign a NIP-98 header with the operator's key for an origin + // the connected relay cannot vouch for. Pre-fill the manual field + // and open Advanced so the operator can review and Save explicitly. + setOriginInput(discovered.origin); + setAdvancedOpen(true); + setSavedOriginBoth(null); + return; + } + // Same-host advertisement: auto-save the discovered origin (same path + // as an explicit Save), then probe. This lets the panel render + // immediately on first open when the relay advertises its own + // admin_api, with no Save required. The operator still sees the + // Advanced disclosure if they need to change or clear the value. + try { + const canonical = await setAdminOrigin( + discovered.origin, + pubkeyHex, + ); + if (!active) return; + if (canonical) { + setSavedOriginBoth(canonical); + setOriginInput(canonical); + runProbe(canonical); + return; + } + } catch { + // Discovery save failed (e.g. invalid origin per AdminOrigin::parse). + // Fall through to manual-entry state. + } + if (!active) return; + // Save failed: pre-fill only so the operator can review and Save manually. + setOriginInput(discovered.origin); + setAdvancedOpen(true); + } + setSavedOriginBoth(null); + } catch (e) { + if (!active) return; + // Surface storage/signing errors rather than silently degrading. + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + setSavedOriginBoth(null); + setOriginInput(""); + } + })(); + return () => { + active = false; + }; + }, []); // Empty: runs once per session mount; identity boundary is the key prop. + + function runProbe(origin: string) { + probeAbortRef.current?.abort(); + const controller = new AbortController(); + probeAbortRef.current = controller; + + setProbeUiState({ kind: "probing" }); + + void (async () => { + try { + const result = await probeAdminOrigin(origin); + if (controller.signal.aborted) return; + setProbeUiState(probeStateToUiState(result, origin, pubkeyHex)); + } catch (e) { + if (controller.signal.aborted) return; + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } + })(); + } + + async function handleSave() { + const trimmed = originInput.trim(); + // Capture (pubkey, origin) token at save-start time. The check below + // ensures a delayed completion cannot write into a different session. + const token: SessionToken = { pubkey: pubkeyHex, origin: trimmed }; + sessionTokenRef.current = token; + + setIsSaving(true); + abortAndResetProbe(); + try { + if (!trimmed) { + const canonical = await setAdminOrigin(null, pubkeyHex); + // Discard if the session changed while the native call was in flight. + if (sessionTokenRef.current !== token) return; + setSavedOriginBoth(canonical); + setProbeUiState({ kind: "idle" }); + return; + } + const canonical = await setAdminOrigin(trimmed, pubkeyHex); + if (sessionTokenRef.current !== token) return; + setSavedOriginBoth(canonical); + if (canonical) { + runProbe(canonical); + } else { + setProbeUiState({ kind: "idle" }); + } + } catch (e) { + if (sessionTokenRef.current !== token) return; + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + if (sessionTokenRef.current === token) setIsSaving(false); + } + } + + const inputChanged = originInput.trim() !== (savedOrigin ?? ""); + const isPanelVisible = + (probeUiState.kind === "authorized" || probeUiState.kind === "disabled") && + savedOrigin !== null; + + return ( + <> + {/* Probe status badge — always shown above the panel/disclosure */} +
    + +
    + + {isPanelVisible && savedOrigin && ( + { + // Fence: the panel was mounted for `savedOrigin`. If the origin + // changed while Staffing's mutation was in flight (e.g. operator + // saved a new origin before A's DELETE resolved), the captured + // value no longer matches the current session — ignore the + // completion rather than probing the stale relay and overwriting + // the new session's authorized state. + const originAtRender = savedOrigin; + if (savedOriginRef.current === originAtRender) { + runProbe(originAtRender); + } + }} + /> + )} + + {/* Advanced: admin origin — moved below the action panel; happy path + never needs it so it lives at the bottom of the section. */} +
    +
    setAdvancedOpen(e.currentTarget.open)} + > + + + Advanced: admin origin + +
    +
    + { + setOriginInput(e.target.value); + // General reset: abort and clear probe state on every input + // change, not only when state is `probing`. This prevents a + // stale probe result from a previous value being committed. + abortAndResetProbe(); + }} + placeholder="https://admin.yourrelay.example.com" + spellCheck={false} + type="url" + value={originInput} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave(); + }} + /> + + {savedOrigin && ( + + )} +
    + {probeUiState.kind === "authorized" && probeUiState.source && ( +

    + Origin resolved from{" "} + {probeUiState.source === "config" + ? "relay config" + : probeUiState.source === "owner_fallback" + ? "relay config (owner fallback)" + : "database"} + . +

    + )} +
    +
    +
    + + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx b/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx new file mode 100644 index 0000000000..f5a110833c --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx @@ -0,0 +1,799 @@ +/** + * Staffing tab — Operator-only UI for managing relay_operators rows. + * + * Source badges distinguish config-backed entries (immutable via API) from + * DB-managed entries (can be added/removed/updated). 409 conflicts from the + * server (config-backed key modification attempts) are surfaced with a clear + * message. + * + * Display-name resolution follows the same pattern as the Invites surface: + * names come from `useUsersBatchQuery`; hovering a name cross-fades to the + * truncated npub so the raw identity is always one interaction away. + * + * A "Restrictions" section below the operator list lets operators lift active + * bans and timeouts for the currently active community. + */ + +import { useEffect, useRef, useState } from "react"; +import { nip19 } from "nostr-tools"; +import { LoaderCircle, Trash2 } from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { getRelayWsUrl } from "@/shared/api/tauri"; +import type { UserProfileSummary } from "@/shared/api/types"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + deleteAdminOperator, + listAdminOperators, + listAdminRestrictions, + liftAdminBan, + liftAdminTimeout, + putAdminOperator, + type AdminMemberRestrictionDto, + type AdminOperatorDto, +} from "./api"; +import { + adminErrorMessage, + type AsyncState, + ErrorMessage, + LoadingSpinner, + useAsyncLoad, +} from "./AdminConsolePanelHelpers"; + +// ── Display-name helpers (mirrors CommunityMembersSettingsCard) ─────────── + +function formatDisplayName( + pubkey: string, + profile?: UserProfileSummary | null, +): string { + const trimmed = profile?.displayName?.trim(); + if (trimmed && !trimmed.toLowerCase().startsWith("npub1")) { + return trimmed; + } + return truncatePubkey(pubkey); +} + +function npubFromPubkey(pubkey: string): string | null { + try { + return nip19.npubEncode(pubkey); + } catch { + return null; + } +} + +/** + * Cross-fades between display name and npub on hover — same animation as the + * Invites / CommunityMembersSettingsCard surface so the two feel identical. + */ +function HoverStaffingIdentity({ + pubkey, + displayName, +}: { + pubkey: string; + displayName: string; +}) { + const npub = npubFromPubkey(pubkey) ?? pubkey; + return ( + + + {displayName} + + + {truncatePubkey(npub)} + + + ); +} + +// ── Source badge ────────────────────────────────────────────────────────── + +/** Source badge for an operator entry. */ +function SourceBadge({ + source, +}: { + source: "config" | "owner_fallback" | "db"; +}) { + const label: Record = { + config: "config", + owner_fallback: "owner (fallback)", + db: "db", + }; + const variant: Record = { + config: "secondary", + owner_fallback: "secondary", + db: "outline", + }; + return ( + + {label[source] ?? source} + + ); +} + +// ── Restrictions section ────────────────────────────────────────────────── + +/** + * Restriction type label for a row. A row can be banned, timed-out, or both. + */ +function RestrictionTypeBadge({ + record, +}: { + record: AdminMemberRestrictionDto; +}) { + const now = new Date(); + const isBanned = + record.banned && + (record.banExpiresAt === null || new Date(record.banExpiresAt) > now); + const isTimedOut = + record.mutedUntil !== null && new Date(record.mutedUntil) > now; + return ( + + {isBanned && banned} + {isTimedOut && timeout} + + ); +} + +/** + * Active restrictions for the active relay's community. The native commands + * name the community by the active relay's host, so there is no client-side + * community id to get wrong; an unresolvable host surfaces as an error. + * Operators can lift bans and timeouts per member row. + */ +function RestrictionsSection({ + origin, + relayKey, + generation, +}: { + origin: string; + /** Active relay identity; a change reloads the list. */ + relayKey: string; + generation: number; +}) { + const [listGen, setListGen] = useState(0); + const [liftError, setLiftError] = useState(null); + const [workingPubkey, setWorkingPubkey] = useState(null); + /** Row pending a lift-ban confirmation. */ + const [pendingLiftBan, setPendingLiftBan] = + useState(null); + /** Row pending a lift-timeout confirmation. */ + const [pendingLiftTimeout, setPendingLiftTimeout] = + useState(null); + + // The list captures the native relay it loaded from; every later page and + // removal carries it so a relay switch fails loudly instead of retargeting. + const listState: AsyncState<{ + relay: string; + items: AdminMemberRestrictionDto[]; + nextCursor: string | null; + }> = useAsyncLoad( + async () => { + const relay = await getRelayWsUrl(); + return { relay, ...(await listAdminRestrictions(origin, relay)) }; + }, + [origin, relayKey], + generation + listGen, + ); + const loadedRelay = listState.status === "ok" ? listState.data.relay : ""; + + const handleConfirmLiftBan = async () => { + const row = pendingLiftBan; + if (!row) return; + setPendingLiftBan(null); + setLiftError(null); + setWorkingPubkey(row.pubkey); + try { + await liftAdminBan(origin, row.pubkey, loadedRelay); + setListGen((g) => g + 1); + } catch (e) { + const msg = adminErrorMessage(e); + // 409 = no active ban — treat as a soft success (already gone). + if (msg.includes("no active ban") || msg.includes("conflict")) { + setListGen((g) => g + 1); + } else { + setLiftError(msg); + } + } finally { + setWorkingPubkey(null); + } + }; + + const handleConfirmLiftTimeout = async () => { + const row = pendingLiftTimeout; + if (!row) return; + setPendingLiftTimeout(null); + setLiftError(null); + setWorkingPubkey(row.pubkey); + try { + await liftAdminTimeout(origin, row.pubkey, loadedRelay); + setListGen((g) => g + 1); + } catch (e) { + const msg = adminErrorMessage(e); + // 409 = no active timeout — treat as a soft success (already gone). + if (msg.includes("no active timeout") || msg.includes("conflict")) { + setListGen((g) => g + 1); + } else { + setLiftError(msg); + } + } finally { + setWorkingPubkey(null); + } + }; + + // Pages past the first, tied to the load generation so any reload (e.g. + // after a lift) drops them and restarts from page one. + const loadGen = generation + listGen; + const [more, setMore] = useState<{ + gen: number; + items: AdminMemberRestrictionDto[]; + nextCursor: string | null; + } | null>(null); + // Busy/error for the in-flight extra page, fenced by the same generation: + // a page request that settles after a reload must not touch the new list. + const [moreRequest, setMoreRequest] = useState<{ + gen: number; + busy: boolean; + error: string | null; + } | null>(null); + const loadGenRef = useRef(loadGen); + useEffect(() => { + loadGenRef.current = loadGen; + }, [loadGen]); + const extra = more?.gen === loadGen ? more : null; + const moreStatus = moreRequest?.gen === loadGen ? moreRequest : null; + const items = + listState.status === "ok" + ? [...listState.data.items, ...(extra?.items ?? [])] + : []; + const nextCursor = + listState.status === "ok" + ? extra + ? extra.nextCursor + : listState.data.nextCursor + : null; + + const handleLoadMore = async () => { + if (!nextCursor) return; + const gen = loadGen; + setMoreRequest({ gen, busy: true, error: null }); + try { + const page = await listAdminRestrictions(origin, loadedRelay, nextCursor); + if (loadGenRef.current !== gen) return; + setMore({ + gen, + items: [...(extra?.items ?? []), ...page.items], + nextCursor: page.nextCursor, + }); + setMoreRequest({ gen, busy: false, error: null }); + } catch (e) { + if (loadGenRef.current !== gen) return; + setMoreRequest({ gen, busy: false, error: adminErrorMessage(e) }); + } + }; + + return ( +
    + {/* Lift-ban confirmation dialog */} + { + if (!open) setPendingLiftBan(null); + }} + > + + + Lift ban? + + This will remove the active ban for{" "} + + {pendingLiftBan ? truncatePubkey(pendingLiftBan.pubkey) : ""} + + . They will be able to post again. + + + + + Cancel + + + + + + + + + {/* Lift-timeout confirmation dialog */} + { + if (!open) setPendingLiftTimeout(null); + }} + > + + + Clear timeout? + + This will clear the active timeout for{" "} + + {pendingLiftTimeout + ? truncatePubkey(pendingLiftTimeout.pubkey) + : ""} + + . They will be able to post again. + + + + + Cancel + + + + + + + + +

    + Active restrictions +

    + + {listState.status === "loading" && } + {listState.status === "error" && ( + + )} + {liftError && } + {listState.status === "ok" && items.length === 0 && ( +

    + No active bans or timeouts. +

    + )} + {listState.status === "ok" && items.length > 0 && ( +
      + {items.map((row) => { + const isWorking = workingPubkey === row.pubkey; + const now = new Date(); + const isBanned = + row.banned && + (row.banExpiresAt === null || new Date(row.banExpiresAt) > now); + const isTimedOut = + row.mutedUntil !== null && new Date(row.mutedUntil) > now; + return ( +
    • +
      +

      + {truncatePubkey(row.pubkey)} +

      +
      + +
      +
      + {isBanned && ( + + )} + {isTimedOut && ( + + )} +
    • + ); + })} +
    + )} + {moreStatus?.error && } + {nextCursor && ( + + )} +
    + ); +} + +// ── Staffing tab ────────────────────────────────────────────────────────── + +export function StaffingTab({ + origin, + pubkey, + generation, + canMutate, + onSelfMutation, +}: { + origin: string; + pubkey: string; + generation: number; + /** + * When false (disabled-auth probe), all write affordances are hidden. + * The operator list is still readable; only add/remove/edit controls are absent. + */ + canMutate: boolean; + /** + * Called after a successful mutation that modified the current principal's + * own operator row (role change or removal of self). The parent re-probes + * the admin origin so the displayed role and visible tabs reflect the new + * server state. + */ + onSelfMutation?: () => void; +}) { + const { activeCommunity } = useCommunities(); + const [listGen, setListGen] = useState(0); + const [addPubkey, setAddPubkey] = useState(""); + const [addRole, setAddRole] = useState<"operator" | "moderator">("moderator"); + const [isAdding, setIsAdding] = useState(false); + const [addError, setAddError] = useState(null); + const [actionError, setActionError] = useState(null); + const [workingPubkey, setWorkingPubkey] = useState(null); + /** Operator pending removal confirmation; null when dialog is closed. */ + const [pendingRemove, setPendingRemove] = useState( + null, + ); + + const listState: AsyncState = useAsyncLoad( + () => listAdminOperators(origin), + [origin, pubkey], + generation + listGen, + ); + + // Resolve display names using the shared hook — normalised delta-fetch with + // per-pubkey caching, persisted-label seeding, and focus-recovery retry. + // CommunitiesProvider wraps the entire Settings tree (main.tsx) so this is + // always in context. + const listedPubkeys = + listState.status === "ok" ? listState.data.map((op) => op.pubkey) : []; + const profilesQuery = useUsersBatchQuery(listedPubkeys, { + enabled: listedPubkeys.length > 0, + }); + + const handleAdd = async () => { + const trimmed = addPubkey.trim().toLowerCase(); + if (!trimmed) return; + // Enforce create-only invariant: reject if the roster hasn't loaded — + // the disabled button is the primary UI gate, but this guard closes the + // boundary at the mutation call site itself. + if (listState.status !== "ok") return; + setAddError(null); + + // Reject any pubkey already present in the authoritative roster. + const existing = listState.data.find((op) => op.pubkey === trimmed); + if (existing) { + setAddError( + `Already an operator: ${existing.effectiveRole}. Change role with the edit control on their row.`, + ); + return; + } + + setIsAdding(true); + try { + await putAdminOperator(origin, trimmed, addRole); + setAddPubkey(""); + setListGen((g) => g + 1); + } catch (e) { + // Surface the relay's parsed error message — a 409 may indicate either + // a config-backed key (immutable) or a last-operator conflict, both of + // which the relay communicates with distinct messages. + setAddError(adminErrorMessage(e)); + } finally { + setIsAdding(false); + } + }; + + const handleRoleChange = async ( + op: AdminOperatorDto, + newRole: "operator" | "moderator", + ) => { + if (newRole === op.effectiveRole) return; + setActionError(null); + setWorkingPubkey(op.pubkey); + try { + await putAdminOperator(origin, op.pubkey, newRole); + setListGen((g) => g + 1); + // Self-demotion: re-probe so the parent updates role badge and tab + // visibility to reflect the new server state. + if (op.pubkey === pubkey) { + onSelfMutation?.(); + } + } catch (e) { + setActionError(adminErrorMessage(e)); + } finally { + setWorkingPubkey(null); + } + }; + + const handleConfirmRemove = async () => { + const op = pendingRemove; + if (!op) return; + setPendingRemove(null); + setActionError(null); + setWorkingPubkey(op.pubkey); + try { + await deleteAdminOperator(origin, op.pubkey); + setListGen((g) => g + 1); + // Self-removal: re-probe so the parent updates role badge and tab + // visibility to reflect the new server state. + if (op.pubkey === pubkey) { + onSelfMutation?.(); + } + } catch (e) { + setActionError(adminErrorMessage(e)); + } finally { + setWorkingPubkey(null); + } + }; + + const isSelf = pendingRemove?.pubkey === pubkey; + + return ( +
    + {/* Remove confirmation dialog */} + { + if (!open) setPendingRemove(null); + }} + > + + + Remove operator? + +
    +

    + This will remove{" "} + + {pendingRemove ? truncatePubkey(pendingRemove.pubkey) : ""} + {" "} + ({pendingRemove?.effectiveRole}) from the operator list. +

    + {isSelf && ( +

    + You are removing your own operator access. Once removed, you + may lose the ability to undo this action. +

    + )} +
    +
    +
    + + + Cancel + + + + + +
    +
    + + {/* Add operator form — hidden in read-only (disabled-auth) mode */} + {canMutate && ( +
    +

    + Add operator +

    +
    + setAddPubkey(e.target.value)} + placeholder="64-hex pubkey" + type="text" + value={addPubkey} + /> + + +
    + {addError &&

    {addError}

    } +
    + )} + + {/* Operator list */} + {listState.status === "loading" && } + {listState.status === "error" && ( + + )} + {actionError && } + {listState.status === "ok" && ( +
      + {listState.data.length === 0 && ( +

      + No operators configured. +

      + )} + {listState.data.map((op: AdminOperatorDto) => { + const isConfigBacked = op.sources.some( + (s) => s === "config" || s === "owner_fallback", + ); + const isWorking = workingPubkey === op.pubkey; + const profile = profilesQuery.data?.profiles[op.pubkey]; + const displayName = formatDisplayName(op.pubkey, profile); + return ( +
    • +
      +

      + +

      +
      + {op.sources.map((s) => ( + + ))} +
      +
      + {/* In-place role selector — hidden in read-only / config-backed mode */} + {canMutate && !isConfigBacked && ( + + )} + {/* Config-backed: show role as badge (not editable) */} + {isConfigBacked && ( + {op.effectiveRole} + )} + {/* Remove button — hidden in read-only (disabled-auth) mode */} + {canMutate && ( + + )} +
    • + ); + })} +
    + )} + + {/* Restrictions section — active bans and timeouts for the current community */} +
    + +
    +
    + ); +} diff --git a/desktop/src/features/admin-console/adminConsolePanel.test.mjs b/desktop/src/features/admin-console/adminConsolePanel.test.mjs new file mode 100644 index 0000000000..aedd9ff124 --- /dev/null +++ b/desktop/src/features/admin-console/adminConsolePanel.test.mjs @@ -0,0 +1,1220 @@ +/** + * Behavior and race tests for AdminConsoleSettingsCard / AdminConsoleSettingsSession. + * + * Tests mount the REAL production components (including the key-prop session + * boundary, sessionTokenRef fence, and abortAndResetProbe wiring) against a + * mocked Tauri IPC bridge and a real QueryClientProvider. + * + * This file uses the hand-rolled MinimalDocument shim (same pattern as + * useLoadArchivedObserverEvents.test.mjs) and covers prop-driven and query- + * driven tests that do NOT require native event dispatch through React 19's + * container-level delegation: + * + * What makes these tests authoritative — they fail if: + * - `pubkeyHex ? : null` render gate removed (authorized-logout-teardown) + * - `key={pubkeyHex}` boundary is removed (identity-switch test) + * - `active` flag cleanup is removed from useAsyncLoad (old-list-after-new-list) + * - the `getAdminOrigin()` catch is changed to silent-degrade (storage-error test) + * + * authorized-logout-teardown lives here (MinimalDocument, not jsdom) because the test is + * query-driven (act + qc.setQueryData + settle), not event-driven. The MinimalDocument + * suite handles async transitions cleanly without the jsdom global scheduler. + * + * Cross-identity delayed-save and all event-driven tests (origin-edit, detail-navigation, + * blob-leak-on-back-navigation, same-session-save-race) live in adminConsolePanelSession.jsdom-test.mjs + * where fireEvent dispatches native events through React 19's container-level delegation. + * + * Also covers: + * - parseImetaAttachments wire contract (imported from AdminConsolePanel) + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +// ── Minimal DOM shim ────────────────────────────────────────────────────────── +// +// Installs the minimum DOM surface that React + react-dom/client need. +// Uses the same pattern as useLoadArchivedObserverEvents.test.mjs to avoid +// jsdom background timers that prevent the process from exiting cleanly. + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName?.toUpperCase?.() ?? tagName; + this.nodeName = this.tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + this.attributes = []; + this._data = {}; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.childNodes[0] ?? null; + } + get lastChild() { + return this.childNodes[this.childNodes.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get previousSibling() { + return null; + } + get nodeValue() { + return null; + } + set nodeValue(_v) {} + get textContent() { + return this.childNodes.map((c) => c.textContent ?? "").join(""); + } + set textContent(v) { + this.childNodes = []; + if (v) { + const t = globalThis.document.createTextNode(v); + this.appendChild(t); + } + } + appendChild(child) { + child.parentNode = this; + this.childNodes.push(child); + if (child.nodeType === 1) this.children.push(child); + return child; + } + removeChild(child) { + this.childNodes = this.childNodes.filter((c) => c !== child); + this.children = this.children.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.childNodes.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + newNode.parentNode = this; + this.childNodes.splice(i, 0, newNode); + if (newNode.nodeType === 1) this.children.push(newNode); + return newNode; + } + replaceChild(newNode, oldNode) { + const i = this.childNodes.indexOf(oldNode); + if (i >= 0) { + newNode.parentNode = this; + this.childNodes[i] = newNode; + const j = this.children.indexOf(oldNode); + if (j >= 0) this.children[j] = newNode; + } + return oldNode; + } + contains(node) { + if (!node) return false; + return this === node || this.childNodes.some((c) => c?.contains?.(node)); + } + setAttribute(name, value) { + this._data[name] = value; + } + getAttribute(name) { + return this._data[name] ?? null; + } + hasAttribute(name) { + return Object.hasOwn(this._data, name); + } + removeAttribute(name) { + delete this._data[name]; + } + querySelector(selector) { + // Support [data-testid='...'] and simple tag selectors. + const attrMatch = selector.match(/\[([^\]=']+)(?:='([^']*)')?\]/); + const tagMatch = selector.match(/^([a-zA-Z]+)$/); + for (const node of this._allElements()) { + if (attrMatch) { + const [, attrName, attrVal] = attrMatch; + const nodeVal = node.getAttribute?.(attrName); + if (attrVal === undefined ? nodeVal !== null : nodeVal === attrVal) { + return node; + } + } else if (tagMatch) { + if (node.tagName?.toLowerCase() === tagMatch[1].toLowerCase()) { + return node; + } + } + } + return null; + } + querySelectorAll(selector) { + const attrMatch = selector.match(/\[([^\]=']+)(?:='([^']*)')?\]/); + const results = []; + for (const node of this._allElements()) { + if (attrMatch) { + const [, attrName, attrVal] = attrMatch; + const nodeVal = node.getAttribute?.(attrName); + if (attrVal === undefined ? nodeVal !== null : nodeVal === attrVal) { + results.push(node); + } + } + } + return results; + } + *_allElements() { + for (const child of this.childNodes) { + yield child; + if (child._allElements) yield* child._allElements(); + } + } + get innerHTML() { + return this.childNodes + .map((c) => c.outerHTML ?? c.textContent ?? "") + .join(""); + } + set innerHTML(_v) {} + get outerHTML() { + return `<${this.tagName?.toLowerCase() ?? "div"}>...`; + } + focus() {} + blur() {} + getBoundingClientRect() { + return { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }; + } + cloneNode() { + return new MinimalNode(this.tagName); + } + get value() { + return this._value ?? ""; + } + set value(v) { + this._value = v; + } + get disabled() { + return this._disabled ?? false; + } + set disabled(v) { + this._disabled = v; + } + get type() { + return this._type ?? ""; + } + set type(v) { + this._type = v; + } + get checked() { + return this._checked ?? false; + } + set checked(v) { + this._checked = v; + } + get className() { + return this._className ?? ""; + } + set className(v) { + this._className = v; + } + get id() { + return this._id ?? ""; + } + set id(v) { + this._id = v; + } + get placeholder() { + return this._placeholder ?? ""; + } + set placeholder(v) { + this._placeholder = v; + } + get readOnly() { + return this._readOnly ?? false; + } + set readOnly(v) { + this._readOnly = v; + } + get tabIndex() { + return this._tabIndex ?? -1; + } + set tabIndex(v) { + this._tabIndex = v; + } + get href() { + return this._href ?? ""; + } + set href(v) { + this._href = v; + } + get src() { + return this._src ?? ""; + } + set src(v) { + this._src = v; + } + get alt() { + return this._alt ?? ""; + } + set alt(v) { + this._alt = v; + } + } + + class MinimalTextNode extends MinimalEventTarget { + constructor(value) { + super(); + this.nodeType = 3; + this.nodeName = "#text"; + this.nodeValue = value; + this.parentNode = null; + } + get textContent() { + return this.nodeValue; + } + set textContent(v) { + this.nodeValue = v; + } + contains(node) { + return this === node; + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + this.nodeName = "#document"; + this._body = null; + this._head = null; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + return new MinimalTextNode(value); + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeType = 8; + n.nodeValue = value; + return n; + } + createElementNS(_ns, tagName) { + return this.createElement(tagName); + } + get body() { + if (!this._body) { + this._body = this.createElement("body"); + } + return this._body; + } + get head() { + if (!this._head) { + this._head = this.createElement("head"); + } + return this._head; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + querySelector(sel) { + return this.body.querySelector(sel); + } + querySelectorAll(sel) { + return this.body.querySelectorAll(sel); + } + get documentElement() { + return this.body; + } + } + + const doc = new MinimalDocument(); + globalThis.document = doc; + globalThis.HTMLElement = MinimalNode; + globalThis.HTMLInputElement = MinimalNode; + globalThis.HTMLButtonElement = MinimalNode; + globalThis.HTMLDivElement = MinimalNode; + globalThis.HTMLSpanElement = MinimalNode; + globalThis.HTMLAnchorElement = MinimalNode; + globalThis.HTMLFormElement = MinimalNode; + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.SVGElement = MinimalNode; + globalThis.SVGSVGElement = MinimalNode; + globalThis.Text = MinimalTextNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + globalThis.getComputedStyle = () => ({ + getPropertyValue: () => "", + setProperty: () => {}, + }); + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + // CommunitiesProvider reads localStorage on mount; provide a minimal no-op + // shim so tests that wrap components needing CommunitiesProvider don't throw. + if (typeof globalThis.localStorage === "undefined") { + const _store = Object.create(null); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (k) => _store[k] ?? null, + setItem: (k, v) => { + _store[k] = String(v); + }, + removeItem: (k) => { + delete _store[k]; + }, + clear: () => { + for (const k of Object.keys(_store)) delete _store[k]; + }, + get length() { + return Object.keys(_store).length; + }, + key: (i) => Object.keys(_store)[i] ?? null, + }, + configurable: true, + }); + } +} + +installDOMShim(); + +// ── Tauri IPC interceptor ───────────────────────────────────────────────────── + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +function setIpcHandler(cmd, fn) { + ipcHandlers.set(cmd, fn); +} +function clearIpcHandlers() { + ipcHandlers.clear(); +} + +globalThis.__TAURI_INTERNALS__ = { + invoke(cmd, args) { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback(_cb) { + return Math.random(); + }, +}; + +// ── Production imports ──────────────────────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; + +import { AdminConsoleSettingsCard } from "./AdminConsoleSettingsCard.tsx"; +import { + AdminConsolePanel, + parseImetaAttachments, +} from "./AdminConsolePanel.tsx"; +import { applyAttachmentBudget } from "./AdminConsoleFeedbackTab.tsx"; + +// ── Deferred promise helper ─────────────────────────────────────────────────── + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ── Mount helpers ───────────────────────────────────────────────────────────── + +function makeQueryClient(pubkeyHex) { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + // Always set identity to an object (even for empty pubkey) so React Query + // never calls queryFn = getIdentity (which would hit the unmocked IPC). + // Component reads pubkeyHex = identity?.pubkey ?? "" — so { pubkey: "" } + // gives pubkeyHex = "" (logged-out state). + qc.setQueryData(["identity"], { pubkey: pubkeyHex }); + return qc; +} + +function mountCard(qc) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async () => { + // ReportsTab calls useUsersBatchQuery which needs a get_users_batch handler. + if (!ipcHandlers.get("get_users_batch")) { + setIpcHandler("get_users_batch", () => + Promise.resolve({ profiles: {}, missing: [] }), + ); + } + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(AdminConsoleSettingsCard), + ), + ), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + qc.clear(); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +/** + * Mount AdminConsolePanel directly (not through the settings card). + * Used for panel-level race tests (list, detail, attachment). + */ +function mountPanel({ + origin, + pubkey, + canMutate = true, + initialTab = undefined, +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = makeQueryClient(pubkey); + const root = createRoot(container); + const doRender = async ({ origin: o, pubkey: p } = { origin, pubkey }) => { + // ReportsTab calls useUsersBatchQuery which needs QueryClientProvider + + // CommunitiesProvider. Provide a default no-op handler so profile lookups + // resolve without error when individual tests don't override get_users_batch. + if (!ipcHandlers.get("get_users_batch")) { + setIpcHandler("get_users_batch", () => + Promise.resolve({ profiles: {}, missing: [] }), + ); + } + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(AdminConsolePanel, { + canMutate, + origin: o, + pubkey: p, + ...(initialTab !== undefined ? { initialTab } : {}), + }), + ), + ), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + qc.clear(); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +// Flush React effects and timers. +async function settle(ms = 20) { + await act(async () => { + await new Promise((r) => setTimeout(r, ms)); + }); +} + +afterEach(() => { + clearIpcHandlers(); +}); + +// ── parseImetaAttachments ───────────────────────────────────────────────────── + +test("parseImetaAttachments: parses a well-formed imeta tag", () => { + const sha256 = "a".repeat(64); + const tags = [ + [ + "imeta", + `url https://example.com/a.jpg`, + `m image/jpeg`, + `x ${sha256}`, + "size 1234", + ], + ]; + const result = parseImetaAttachments(tags); + assert.equal(result.length, 1); + assert.equal(result[0].sha256, sha256); + assert.equal(result[0].mime, "image/jpeg"); + assert.equal(result[0].size, 1234); +}); + +test("parseImetaAttachments: skips or rejects invalid inputs", () => { + // Table of inputs that must produce an empty result. Retains every input + // from the original standalone tests, including zero/negative sizes and + // null/object/string non-array values. + const sha256 = "a".repeat(64); + const IMETA_REJECTION_ROWS = [ + { + label: "non-imeta tags are skipped", + tags: [ + ["p", "abc123"], + ["e", "def456"], + ], + }, + { + label: "uppercase x hash rejected", + tags: [["imeta", `x ${"A".repeat(64)}`, "m image/png", "size 100"]], + }, + { + label: "hash shorter than 64 chars rejected", + tags: [["imeta", `x ${"a".repeat(63)}`, "m image/png", "size 100"]], + }, + { + label: "hash longer than 64 chars rejected", + tags: [["imeta", `x ${"a".repeat(65)}`, "m image/png", "size 100"]], + }, + { + label: "missing m field rejected", + tags: [["imeta", `x ${"b".repeat(64)}`, "size 100"]], + }, + { + label: "missing size field rejected", + tags: [["imeta", `x ${"c".repeat(64)}`, "m image/png"]], + }, + { + label: "zero size rejected", + tags: [["imeta", `x ${sha256}`, "m image/png", "size 0"]], + }, + { + label: "negative size rejected", + tags: [["imeta", `x ${sha256}`, "m image/png", "size -1"]], + }, + { label: "null input returns empty array", tags: null }, + { label: "object input returns empty array", tags: {} }, + { label: "string input returns empty array", tags: "imeta" }, + ]; + for (const row of IMETA_REJECTION_ROWS) { + assert.deepEqual( + parseImetaAttachments(row.tags), + [], + `row must return []: ${row.label}`, + ); + } +}); + +test("parseImetaAttachments: parses multiple imeta tags", () => { + const sha1 = "e".repeat(64); + const sha2 = "f".repeat(64); + const tags = [ + ["imeta", `x ${sha1}`, "m image/png", "size 111"], + ["imeta", `x ${sha2}`, "m image/jpeg", "size 222"], + ]; + const result = parseImetaAttachments(tags); + assert.equal(result.length, 2); + assert.equal(result[0].sha256, sha1); + assert.equal(result[1].sha256, sha2); +}); + +// ── Component-level session boundary and race tests ─────────────────────────── +// +// Each test below mounts the production AdminConsoleSettingsCard (including +// AdminConsoleSettingsSession keyed by pubkeyHex) and drives Tauri IPC calls +// via deferred promises. These tests fail if the identity boundary or fences +// are removed from the production code. + +test("authorized-logout-teardown: A's session is gone when pubkeyHex becomes empty", async () => { + // Verifies the `pubkeyHex ? : null` render + // gate in AdminConsoleSettingsCard. Drives the full authorized→logout transition: + // mount with a real identity A, drive to authorized (input visible, panel rendered), + // then switch pubkeyHex to "" and assert both input and panel are gone. + // + // Fails if the render gate is removed: after the transition to pubkeyHex="", + // AdminConsoleSettingsSession re-mounts with empty pubkey and the input remains. + // + // Design: identical to identity-switch — act + qc.setQueryData + settle. + // React Query's notifyManager fires onStoreChange via setTimeout(0), which + // act() drains during the inner settle(). The MinimalDocument environment + // handles this cleanly without the jsdom global scheduler side-effects. + + const pubkeyA = "a".repeat(64); + const originA = "https://admin-a.example.com"; + + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + setIpcHandler("admin_probe", () => + Promise.resolve({ state: "nip98Authorized" }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + // A is authorized — input and panel must be present. + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA in authorized state"); + const panelA = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok(panelA, "admin-console-panel must render when A is authorized"); + + // Transition to logout — same pattern as identity-switch. + await act(async () => { + qc.setQueryData(["identity"], { pubkey: "" }); + await new Promise((r) => setTimeout(r, 25)); + }); + + // After the transition: gate renders null, both input and panel must be gone. + const inputAfter = container.querySelector( + "[data-testid='admin-origin-input']", + ); + const panelAfter = container.querySelector( + "[data-testid='admin-console-panel']", + ); + + await unmount(); + + assert.equal( + inputAfter, + null, + "admin origin input must not render when pubkeyHex is empty — render gate missing", + ); + assert.equal( + panelAfter, + null, + "admin-console-panel must not render after logout — render gate missing", + ); +}); +test("identity-switch: fresh session mounts with empty input on pubkey change", async () => { + // Verifies the key-prop boundary. Without `key={pubkeyHex}`, React reuses + // the component and A's origin state survives the switch to B. + + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + const originA = "https://admin-a.example.com"; + + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "disabled" })); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(25); + + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA"); + assert.equal( + inputA.value, + originA, + "input must show A's saved origin after mount", + ); + + // Switch to pubkeyB — key prop causes a full remount of AdminConsoleSettingsSession. + // B has no saved origin, so the input must be empty. + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyB) return Promise.resolve(null); + // Reject any call with A's pubkey — must not fire after the switch. + return Promise.reject(new Error("unexpected pubkey after identity switch")); + }); + + await act(async () => { + qc.setQueryData(["identity"], { pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 25)); + }); + + const inputB = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputB, "input must render for pubkeyB"); + assert.equal( + inputB.value, + "", + "input must be empty for pubkeyB — key boundary ensures fresh state, not stale A origin", + ); + await unmount(); +}); + +test("storage-error surfaced: getAdminOrigin rejection shows error in UI", async () => { + // Verifies the mount-effect catch sets `{ kind: 'error', message }`. + // Removing error propagation from the catch (silent degrade) causes the + // error text to not appear. + + const pubkey = "c".repeat(64); + const errorMsg = "stored admin console origin is invalid (removed): bad json"; + setIpcHandler("get_admin_origin", () => Promise.reject(new Error(errorMsg))); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(25); + + // The error or its key fragment must be visible in the rendered tree. + const bodyText = container.textContent ?? ""; + const hasError = + bodyText.includes("invalid") || + bodyText.includes("bad json") || + bodyText.includes("removed") || + bodyText.includes("admin console origin"); + assert.ok( + hasError, + `error from getAdminOrigin must appear in UI; body text: "${bodyText.slice(0, 300)}"`, + ); + await unmount(); +}); + +// origin-edit (abortAndResetProbe wired to onChange) is covered by +// adminConsolePanelSession.jsdom-test.mjs where fireEvent dispatches native +// events through React 19's container-level delegation. + +// ── AdminConsolePanel race tests ────────────────────────────────────────────── +// +// These tests mount AdminConsolePanel directly (bypassing the settings card) +// and use deferred promises to simulate in-flight native requests. They verify +// the effect-local `active` flag cancellation in useAsyncLoad, the generation +// fence in AdminConsolePanel, and the loadGenRef cleanup in AttachmentViewer. + +test("old-list-after-new-list: stale list result does not replace new list after pubkey change", async () => { + // Verifies the effect-local `active` flag in useAsyncLoad. + // + // Scenario: panel renders with pubkeyA/originA → list query starts (deferred). + // Before it resolves, panel re-renders with pubkeyB/originB → a new list + // query starts. Then the old (A's) deferred resolves: the active flag in + // A's effect closure is already false (effect re-ran with B's deps), so + // A's result is discarded. Only B's result may commit. + // + // This test fails if useAsyncLoad's active-flag cleanup is removed, because + // A's result would overwrite B's list state. + + const originA = "https://admin-a.example.com"; + const originB = "https://admin-b.example.com"; + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + + const listDeferredA = deferred(); + const listDeferredB = deferred(); + + // First call returns A's deferred; subsequent calls return B's. + let callCount = 0; + setIpcHandler("admin_list_reports", () => { + callCount += 1; + if (callCount === 1) return listDeferredA.promise; + return listDeferredB.promise; + }); + + const { container, doRender, unmount } = mountPanel({ + origin: originA, + pubkey: pubkeyA, + }); + + // Render with A — list query starts and stays pending (no settle; would hang). + await act(async () => { + await doRender({ origin: originA, pubkey: pubkeyA }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Switch to B — triggers generation bump + effect cleanup (active = false for A). + // Re-render causes the effect to re-run with B's deps. + await act(async () => { + await doRender({ origin: originB, pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Now resolve A's stale list with a distinct marker item. + listDeferredA.resolve([ + { + id: "00000000-0000-0000-0000-000000000001", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aabb", + reporterPubkey: "ccdd", + targetKind: "message", + target: "eeff", + reportType: "spam", + status: "STALE-A-RESULT", + createdAt: "2024-01-01T00:00:00Z", + }, + ]); + + // Flush A's resolution — active is false so it must not commit. + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + // A's stale result must not appear — active flag was false. + const text = container.textContent ?? ""; + assert.ok( + !text.includes("STALE-A-RESULT"), + `stale list result from A must not appear after B renders; got: ${text.slice(0, 300)}`, + ); + + // Resolve B's list — this one is live. + listDeferredB.resolve([ + { + id: "00000000-0000-0000-0000-000000000003", + communityId: "00000000-0000-0000-0000-000000000004", + communityHost: "relay.example.com", + reportEventId: "1122", + reporterPubkey: "3344", + targetKind: "message", + target: "5566", + reportType: "feedback", + status: "LIVE-B-RESULT", + createdAt: "2024-01-02T00:00:00Z", + }, + ]); + + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const textAfter = container.textContent ?? ""; + assert.ok( + textAfter.includes("LIVE-B-RESULT"), + `B's live list result must appear; got: ${textAfter.slice(0, 300)}`, + ); + + await unmount(); +}); + +// detail-navigation and blob-leak-on-back-navigation (useAsyncLoad active flag, +// AttachmentViewer loadGenRef cleanup) are covered by +// adminConsolePanelSession.jsdom-test.mjs where fireEvent dispatches native +// events through React 19's container-level delegation. + +// ── probe role/source gating — table-driven ────────────────────────────── +// +// Five rows cover the full probe-state → role-gate matrix. Each row shares +// the standard MinimalDocument mount; unique scheduler-sensitive assertions +// (disabled-probe-mounts-panel, probe-no-role) stay in this environment. +// +// Mutation evidence per row is preserved inline. + +const PROBE_ROLE_ROWS = [ + { + name: "disabled-probe-mounts-panel", + desc: "admin-console-panel renders when probe state is disabled", + pubkey: "f".repeat(64), + savedOrigin: "https://admin.example.com", + probeResult: { state: "disabled" }, + // Fails if render gate reverts to authorized-only (disabled state never mounts panel). + check: (container) => { + const panel = container.querySelector( + "[data-testid='admin-console-panel']", + ); + assert.ok( + panel !== null, + "admin-console-panel must mount when probe state is disabled — render gate missing", + ); + const text = container.textContent ?? ""; + assert.ok( + text.includes("Auth is disabled"), + `disabled badge must remain visible; got: ${text.slice(0, 300)}`, + ); + }, + }, + { + name: "probe-role-source-badge", + desc: "operator role and config source render in panel when probe returns them", + pubkey: "b1".repeat(32), + savedOrigin: "https://admin-role.example.com", + probeResult: { + state: "nip98Authorized", + role: "operator", + source: "config", + }, + // Mutation: remove role/source from AdminProbeResult → badges absent → RED. + check: (container) => { + const text = container.textContent ?? ""; + assert.ok( + text.includes("operator"), + `role badge "operator" must render; got: ${text.slice(0, 300)}`, + ); + assert.ok( + text.includes("config"), + `source badge "config" must render; got: ${text.slice(0, 300)}`, + ); + }, + }, + { + name: "probe-moderator-role", + desc: "moderator role renders without staffing tab", + pubkey: "c2".repeat(32), + savedOrigin: "https://admin-mod.example.com", + probeResult: { state: "nip98Authorized", role: "moderator", source: "db" }, + check: (container) => { + const text = container.textContent ?? ""; + assert.ok( + text.includes("moderator"), + `role "moderator" must render; got: ${text.slice(0, 300)}`, + ); + assert.equal( + container.querySelector("[data-testid='admin-tab-staffing']"), + null, + "Staffing tab must not render for moderator role", + ); + }, + }, + { + name: "probe-operator-role", + desc: "staffing tab renders for operator role", + pubkey: "d3".repeat(32), + savedOrigin: "https://admin-operator.example.com", + probeResult: { + state: "nip98Authorized", + role: "operator", + source: "config", + }, + check: (container) => { + const staffingTab = container.querySelector( + "[data-testid='admin-tab-staffing']", + ); + assert.ok( + staffingTab !== null, + "Staffing tab must render for operator role", + ); + }, + }, + { + name: "probe-no-role", + desc: "disabled-mode panel renders without staffing tab", + pubkey: "e4".repeat(32), + savedOrigin: "https://admin-disabled.example.com", + probeResult: { state: "disabled" }, + // Badge absence is not asserted here. + check: (container) => { + assert.ok( + container.querySelector("[data-testid='admin-console-panel']") !== null, + "panel must render in disabled mode", + ); + assert.equal( + container.querySelector("[data-testid='admin-tab-staffing']"), + null, + "Staffing tab must not render in disabled mode", + ); + }, + }, +]; + +for (const row of PROBE_ROLE_ROWS) { + test(`${row.name}: ${row.desc}`, async () => { + setIpcHandler("get_admin_origin", () => Promise.resolve(row.savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve(row.probeResult)); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(row.pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + try { + row.check(container); + } finally { + await unmount(); + } + }); +} + +// ── denied badge copy button ────────────────────────────────────────────── + +test("denied-badge-copy-button: copy button is present next to the denied pubkey", async () => { + // Verifies item 2: the pubkey in the denied state is displayed alongside + // a copy button (data-testid="admin-denied-pubkey-copy"), not just a + // cursor-pointer select-all code block. + + const pubkey = "4".repeat(64); + const savedOrigin = "https://admin-denied.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "nip98Denied" })); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + const pubkeyEl = container.querySelector( + "[data-testid='admin-denied-pubkey']", + ); + assert.ok(pubkeyEl !== null, "admin-denied-pubkey element must be present"); + assert.ok( + pubkeyEl.textContent?.includes(pubkey), + `denied pubkey element must contain the pubkey; got: ${pubkeyEl.textContent}`, + ); + + const copyBtn = container.querySelector( + "[data-testid='admin-denied-pubkey-copy']", + ); + assert.ok( + copyBtn !== null, + "admin-denied-pubkey-copy button must be present — copy-icon pattern missing", + ); + + await unmount(); +}); + +// ── P1-2: applyAttachmentBudget — count and aggregate-byte limit ────────── + +test("applyAttachmentBudget: items within count and byte limits pass through unchanged", () => { + const items = [ + { sha256: "a".repeat(64), mime: "image/png", size: 100 }, + { sha256: "b".repeat(64), mime: "image/png", size: 200 }, + ]; + const { shown, truncated } = applyAttachmentBudget(items, 5, 1000); + assert.equal(shown.length, 2); + assert.equal(truncated, 0); +}); + +test("applyAttachmentBudget: excess attachments beyond MAX_COUNT are dropped", () => { + // Build 7 attachments — limit is 5. Excess 2 must not be shown. + // This is the regression Carl required: extra imeta entries on a feedback + // item must NOT result in unbounded fetch fan-out. + const items = Array.from({ length: 7 }, (_, i) => ({ + sha256: String(i).padStart(64, "0"), + mime: "image/png", + size: 100, + })); + const { shown, truncated } = applyAttachmentBudget( + items, + 5, + 50 * 1024 * 1024, + ); + assert.equal( + shown.length, + 5, + "only 5 attachments must be shown when 7 are present", + ); + assert.equal( + truncated, + 2, + "2 excess attachments must be reported as truncated", + ); + // The 6th and 7th items must not appear in shown — verifying the fetch + // fan-out is bounded to the first 5. + assert.ok( + shown.every((a) => Number(a.sha256[0]) < 5), + "shown items must be the first 5 by position", + ); +}); + +test("applyAttachmentBudget: aggregate byte limit drops items that would exceed the ceiling", () => { + // 3 items totalling 30 MiB; cap is 25 MiB. Third item would push us over. + const TEN_MIB = 10 * 1024 * 1024; + const items = [ + { sha256: "a".repeat(64), mime: "image/png", size: TEN_MIB }, + { sha256: "b".repeat(64), mime: "image/png", size: TEN_MIB }, + { sha256: "c".repeat(64), mime: "image/png", size: TEN_MIB }, + ]; + const { shown, truncated } = applyAttachmentBudget( + items, + 5, + 25 * 1024 * 1024, + ); + assert.equal(shown.length, 2, "only 2 items fit within the 25 MiB ceiling"); + assert.equal(truncated, 1); +}); + +test("applyAttachmentBudget: empty list produces empty shown and zero truncated", () => { + const { shown, truncated } = applyAttachmentBudget([], 5, 50 * 1024 * 1024); + assert.equal(shown.length, 0); + assert.equal(truncated, 0); +}); + +// ── P2 round-6 #2: reports-list always calls scope=all ─────────────────── + +test("reports-tab-scope-all: admin_list_reports IPC call includes scope=all", async () => { + // Verifies that the ReportsTab always requests the full workflow queue via + // scope=all, not the relay's escalated-only default (scope omitted). + // + // Mutation evidence: remove `{ scope: "all" }` from the listAdminReports + // call → this test goes RED (captured query has no scope). + + const pubkey = "a9".repeat(32); + const origin = "https://admin-scope.example.com"; + + let capturedQuery = null; + setIpcHandler("admin_list_reports", (args) => { + capturedQuery = args?.query ?? null; + return Promise.resolve([]); + }); + + const { doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + assert.ok(capturedQuery !== null, "admin_list_reports must have been called"); + assert.equal( + capturedQuery?.scope, + "all", + `reports-tab IPC query must include scope="all"; got: ${JSON.stringify(capturedQuery)}`, + ); + + await unmount(); +}); diff --git a/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs new file mode 100644 index 0000000000..244dc4e199 --- /dev/null +++ b/desktop/src/features/admin-console/adminConsolePanelFeedback.jsdom-test.mjs @@ -0,0 +1,891 @@ +/** + * Feedback tab behavior tests for AdminConsolePanel. Covers feedback detail + * rendering, community grouping, status (honest and read-only), severed + * community, list refetch on back-nav, and canMutate gates. + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { + act, + fireEvent, + setIpcHandler, + resetTestState, + mountPanel, + settle, + CM_ORIGIN, + CM_PUBKEY, + capturedToasts, + capturedErrorToasts, + makeCmFalseFeedback, +} from "./adminConsolePanelTestHelpers.jsdom.mjs"; + +afterEach(resetTestState); + +// ── Feedback fixture builder ────────────────────────────────────────────────── +// +// Builds a matched (summary, detail) pair with explicit overrides for the +// fields that vary across feedback test scenarios. Reload handlers, IDs, +// counters, and per-test assertions stay test-local. + +function makeFeedbackFixtures({ + id = "00000000-0000-0000-0000-000000000099", + communityId = "comm-1", + communityHost = "relay.example.com", + submitterPubkey = "sub-fixture", + category = "bug", + bodySummary = "Fixture feedback summary", + body = "Fixture feedback full body", + status = "new", + eventId = "feedevent-fixture", + eventCreatedAt = "2024-06-01T09:00:00Z", + receivedAt = "2024-06-01T09:00:00Z", +} = {}) { + const summary = { + id, + communityId, + communityHost, + submitterPubkey, + category, + bodySummary, + status, + receivedAt, + }; + const detail = { + id, + communityId, + communityHost, + eventId, + submitterPubkey, + category, + body, + status, + tags: [], + eventCreatedAt, + receivedAt, + }; + return { summary, detail }; +} + +test("feedback-detail-renders-structured-fields: FeedbackDetail shows field layout, not raw JSON", async () => { + // Verifies item 3: the feedback detail view renders data-testid='feedback-detail-fields'. + // Lives here (jsdom) because tab switching and item navigation require fireEvent.click. + // + // Mutation evidence: revert FeedbackFields →
    {JSON.stringify(...)}
    + // → this test goes red ("feedback-detail-fields element must render"). + + const origin = "https://admin.example.com"; + const pubkey = "6".repeat(64); + + // Summary shape returned by GET /admin/feedback (FeedbackSummary wire type). + const feedbackSummary = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + submitterPubkey: "submitter001pubkey", + category: "bug", + bodySummary: "App crashes on startup", + status: "new", + receivedAt: "2024-05-01T09:00:05Z", + }; + + // Full AdminFeedbackDto shape returned by GET /admin/feedback/:id. + const feedbackDetail = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + eventId: "feedevent001", + submitterPubkey: "submitter001pubkey", + category: "bug", + body: "App crashes on startup — full detail body text", + status: "new", + tags: [], + eventCreatedAt: "2024-05-01T09:00:00Z", + receivedAt: "2024-05-01T09:00:05Z", + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => + Promise.resolve([feedbackSummary]), + ); + setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackDetail)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Click the Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + + await settle(30); + + // Pre-navigation: list row shows the summary body text (bodySummary rendered). + // Mutation seam: render `body` instead of `bodySummary` → red because summary + // fixture has no `body` field → row title is blank. + const listText = container.textContent ?? ""; + assert.ok( + listText.includes("App crashes on startup"), + `list row must show bodySummary before navigation; got: ${listText.slice(0, 400)}`, + ); + + // Navigate into the feedback detail — click the first non-tab button. + const allButtons = container.querySelectorAll("button"); + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + break; + } + + await settle(30); + + const fields = container.querySelector( + "[data-testid='feedback-detail-fields']", + ); + assert.ok( + fields !== null, + "feedback-detail-fields element must render — JSON dump not replaced", + ); + + const text = container.textContent ?? ""; + assert.ok( + !text.includes('"body":'), + `raw JSON must not be rendered in feedback detail; got: ${text.slice(0, 400)}`, + ); + + // Real DTO fields must render. + assert.ok( + text.includes("submitter001pubkey"), + `submitterPubkey must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("bug"), + `category must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("App crashes on startup"), + `body must render; got: ${text.slice(0, 600)}`, + ); + + // Fake fields must NOT appear. + assert.ok( + !text.includes("appVersion"), + `invented 'appVersion' field must not render; got: ${text.slice(0, 400)}`, + ); + assert.ok( + !text.includes("authorPubkey"), + `invented 'authorPubkey' field must not render; got: ${text.slice(0, 400)}`, + ); + + // Relative timestamp: formatTimestamp output must match "Xm/h/d ago (...)" shape. + // The fixture receivedAt is far in the past, so it will be "Nd ago (...)". + assert.ok( + /\d+[mhd] ago \(/.test(text) || text.includes("just now ("), + `relative timestamp must render in "Nm/h/d ago (...)" format; got: ${text.slice(0, 600)}`, + ); + + await unmount(); +}); + +// ── NIP-11 auto-discovery ───────────────────────────────────────────────── + +test("feedback-grouped-by-community: multi-community feedback renders per-community headings", async () => { + // Same grouping contract for the Feedback tab. + // + // Mutation evidence: revert FeedbackTab to a flat