diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index db34fddc2..a69eafb40 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -4,7 +4,7 @@ name: Auto-tag on Release PR Merge # prefix; the main chart lane also auto-detects a Chart.yaml version bump so # a chart feature PR can publish its own new version when merged: # -# version-bump/ → tag v → release.yml (desktop app) +# version-bump/ → tag desktop-v → release.yml (desktop app) # relay-release/ → tag relay-v → docker.yml (relay image) # chart-release/ → tag chart-v → helm-chart.yml (main helm chart) # push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml @@ -35,6 +35,11 @@ permissions: jobs: auto-tag: + permissions: + contents: read + pull-requests: read + checks: read + statuses: read if: > github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository @@ -57,7 +62,7 @@ jobs: case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" - TAG_PREFIX="v" ;; + TAG_PREFIX="desktop-v" ;; relay-release/*) VERSION="${BRANCH#relay-release/}" TAG_PREFIX="relay-v" ;; @@ -85,9 +90,34 @@ jobs: { echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" + if [[ "$TAG_PREFIX" == desktop-v ]]; then + echo "target_sha=${{ github.event.pull_request.head.sha }}" + echo "desktop=true" + else + echo "target_sha=$GITHUB_SHA" + echo "desktop=false" + fi } >> "$GITHUB_OUTPUT" echo "Tagging ${TAG_PREFIX}${VERSION}" + + - name: Verify immutable reviewed desktop candidate + if: steps.release.outputs.desktop == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.tag }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_PUSHER: ${{ github.event.pull_request.head.user.login }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + VERSION="${VERSION#desktop-v}" + export VERSION + scripts/verify-desktop-release-merge.sh + - name: Create release tagger token if: steps.release.outputs.enabled == 'true' id: release-tagger @@ -102,21 +132,22 @@ jobs: env: GH_TOKEN: ${{ steps.release-tagger.outputs.token }} TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} run: | set -euo pipefail # Check gh's exit status, not its output. A missing ref returns a 404 # JSON body on stdout, which must not be mistaken for an existing tag. if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" - if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation" exit 0 else - echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)" + echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)" exit 1 fi fi gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ - -f sha="$GITHUB_SHA" \ + -f sha="$TARGET_SHA" \ --silent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f03ab3138..1b1e843a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Desktop release candidate contract + run: scripts/test-desktop-release-candidate.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml new file mode 100644 index 000000000..7cc480b93 --- /dev/null +++ b/.github/workflows/prepare-desktop-release.yml @@ -0,0 +1,38 @@ +name: Prepare Desktop Release + +on: + workflow_dispatch: + inputs: + version: + description: Semver to prepare (for example 0.5.1) + required: true + +env: + RELEASE_AUTOMATION_NAME: Carl + RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz + +jobs: + prepare: + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Create short-lived release preparer token + id: preparer + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }} + private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ steps.preparer.outputs.token }} + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Prepare immutable candidate and open or update PR + env: + GH_TOKEN: ${{ steps.preparer.outputs.token }} + VERSION: ${{ inputs.version }} + run: scripts/prepare-desktop-release.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b87e9c8c0..07951ef81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,13 @@ name: Release +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: - - 'v[0-9]*' - workflow_dispatch: - inputs: - version: - description: "Semver version matching the v-prefixed dispatch tag" - required: true + - 'desktop-v[0-9]*' jobs: # Shared setup: verify the immutable release tag, determine the version, and @@ -19,23 +18,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write + contents: read outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.source.outputs.source_sha }} steps: - name: Determine version id: version - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} - run: | - if [[ "$EVENT_NAME" == "push" ]]; then - VERSION="${GITHUB_REF_NAME#v}" - else - VERSION="$INPUT_VERSION" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT" - name: Validate version env: @@ -56,42 +46,9 @@ jobs: env: VERSION: ${{ steps.version.outputs.version }} run: | - scripts/verify-release-ref.sh v "$VERSION" + scripts/verify-release-ref.sh desktop-v "$VERSION" echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT" - - name: Create versioned GitHub release - env: - VERSION: ${{ steps.version.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - RELEASE_SHA=$(git rev-parse HEAD) - NOTES="" - if [[ -f CHANGELOG.md ]]; then - NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md) - fi - if [[ -z "$NOTES" ]]; then - NOTES="Buzz Desktop v${VERSION}" - fi - PRERELEASE_FLAGS=() - if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then - PRERELEASE_FLAGS=(--prerelease --latest=false) - fi - gh release create "v${VERSION}" \ - --target "$RELEASE_SHA" \ - --title "Buzz Desktop v${VERSION}" \ - --notes "$NOTES" \ - "${PRERELEASE_FLAGS[@]}" - - - name: Create rolling auto-update release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create buzz-desktop-latest \ - --prerelease \ - --title "Buzz Desktop Auto-Update" \ - --notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \ - 2>/dev/null || true - release: name: Release if: github.repository == 'block/buzz' @@ -99,7 +56,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -114,7 +71,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -272,13 +229,19 @@ jobs: fi echo "dmg=$DMG" >> "$GITHUB_OUTPUT" - # Find the updater .tar.gz and .sig + # Find the updater .tar.gz and .sig. Give each architecture a unique + # release basename before artifacts are merged by the final writer. ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1) SIG="${ARCHIVE}.sig" if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -289,23 +252,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload arm64 DMG to versioned GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.artifacts.outputs.dmg }} - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Apple Silicon release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-arm64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-macos-x64: name: Release macOS (Intel) @@ -314,7 +269,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -330,7 +285,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -443,6 +398,11 @@ jobs: echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -453,23 +413,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Intel DMG to versioned GitHub release - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.unsigned.outputs.dmg }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Intel macOS release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-x64 + if-no-files-found: error + path: | + ${{ steps.unsigned.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-linux: name: Release Linux @@ -480,7 +432,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read env: # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # Containers lack FUSE, so we must use the extract-and-run fallback. @@ -555,7 +507,7 @@ jobs: - name: Verify tag-bound release source env: VERSION: ${{ needs.setup.outputs.version }} - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -689,29 +641,16 @@ jobs: SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} # NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux) - - name: Upload Linux artifacts to versioned GitHub release - env: - VERSION: ${{ needs.setup.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }} - APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }} - run: | - gh release upload "v$VERSION" \ - "$DEB_PATH" \ - "$APPIMAGE_PATH" \ - --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }} - SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} + - name: Stage Linux release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-linux-x64 + if-no-files-found: error + path: | + ${{ steps.linux-artifacts.outputs.deb }} + ${{ steps.linux-artifacts.outputs.appimage }} + ${{ steps.linux-artifacts.outputs.archive }} + ${{ steps.linux-artifacts.outputs.sig }} release-windows: name: Release Windows @@ -719,7 +658,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} sig: ${{ steps.read-sig.outputs.sig }} @@ -735,7 +674,7 @@ jobs: - name: Verify tag-bound release source shell: bash - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: @@ -745,7 +684,7 @@ jobs: with: node-version: 24.14.1 # Disable dependency caching: a writable cache in this release workflow - # (contents: write, feeds a signed installer) is a poisoning vector. pnpm + # (contents: read, feeds a signed installer) is a poisoning vector. pnpm # install runs uncached below. package-manager-cache: false @@ -827,25 +766,14 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Windows installer to versioned GitHub release - shell: bash - run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXE_PATH: ${{ steps.artifacts.outputs.exe }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - shell: bash - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Windows release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-windows-x64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.exe }} + ${{ steps.artifacts.outputs.sig }} assemble-manifest: name: Assemble multi-platform latest.json @@ -853,7 +781,11 @@ jobs: if: | always() && needs.setup.result == 'success' && - github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) + needs.release.result == 'success' && + needs.release-macos-x64.result == 'success' && + needs.release-linux.result == 'success' && + needs.release-windows.result == 'success' && + github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version) runs-on: ubuntu-latest needs: [setup, release, release-macos-x64, release-linux, release-windows] timeout-minutes: 10 @@ -870,7 +802,26 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" + + - name: Download staged release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-release-* + path: staged-by-platform + + - name: Flatten staged artifacts without basename collisions + run: | + set -euo pipefail + mkdir staged + while IFS= read -r -d '' file; do + name="$(basename "$file")" + [[ ! -e "staged/$name" ]] || { + echo "::error::release artifact basename collision: $name" + exit 1 + } + cp "$file" "staged/$name" + done < <(find staged-by-platform -type f -print0) - name: Write signature files env: @@ -899,7 +850,7 @@ jobs: write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX" write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN" - - name: Verify archive URLs are accessible + - name: Verify draft release has every updater archive env: RESULT_ARM64: ${{ needs.release.result }} RESULT_X64: ${{ needs.release-macos-x64.result }} @@ -911,39 +862,19 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" - ARCHIVES=() - - add_archive() { - local result="$1" platform="$2" archive="$3" - if [[ "$result" == "success" ]]; then - [[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; } - ARCHIVES+=("$archive") - fi - } - - add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64" - add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64" - add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX" - add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN" - - for name in "${ARCHIVES[@]}"; do - echo "Checking $BASE/$name ..." - success=false - for attempt in 1 2 3; do - if curl -fsI "$BASE/$name" > /dev/null 2>&1; then - success=true - break - fi - echo "Attempt $attempt failed for $name, retrying in 10s..." - sleep 10 - done - if [ "$success" != "true" ]; then - echo "::error::Archive not accessible after 3 attempts: $BASE/$name" - exit 1 + assets=$(find staged -type f -exec basename {} \;) + for spec in \ + "$RESULT_ARM64:$ARCHIVE_ARM64" \ + "$RESULT_X64:$ARCHIVE_X64" \ + "$RESULT_LINUX:$ARCHIVE_LINUX" \ + "$RESULT_WIN:$ARCHIVE_WIN"; do + result="${spec%%:*}" + archive="${spec#*:}" + if [[ "$result" == success ]]; then + [[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; } + grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; } fi done - echo "All archive URLs verified." - name: Generate unified latest.json env: @@ -957,7 +888,7 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" + BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}" TRIPLES=() add_triple() { @@ -977,6 +908,45 @@ jobs: bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json - - name: Upload latest.json to rolling release + - name: Create or verify versioned draft run: | - gh release upload buzz-desktop-latest latest.json --clobber + set -euo pipefail + NOTES_FILE="${RUNNER_TEMP}/release-notes.md" + awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE" + [[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; } + PRERELEASE_FLAGS=() + if [[ "$VERSION" == *-* ]]; then + PRERELEASE_FLAGS=(--prerelease --latest=false) + fi + if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then + EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish) + IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft) + [[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || { + echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1; + } + if [[ "$IS_DRAFT" != true ]]; then + echo "already_published=true" >> "$GITHUB_ENV" + fi + else + gh release create "desktop-v${VERSION}" \ + --draft \ + --target "${{ needs.setup.outputs.source_sha }}" \ + --title "Buzz Desktop v${VERSION}" \ + --notes-file "$NOTES_FILE" \ + "${PRERELEASE_FLAGS[@]}" + fi + + - name: Upload complete artifact set to versioned draft + if: env.already_published != 'true' + run: | + mapfile -t files < <(find staged -type f -print) + [[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; } + gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber + + - name: Publish complete versioned release + if: env.already_published != 'true' + run: gh release edit "desktop-v${VERSION}" --draft=false + + - name: Upload latest.json to rolling release last + if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }} + run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/Cargo.lock b/Cargo.lock index 49104b22d..ea5b02aaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.4", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -2177,7 +2190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -3009,8 +3022,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3609,7 +3622,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4501,8 +4514,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "mesh-llm-client", @@ -4511,8 +4524,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4522,13 +4535,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-llm-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4559,8 +4572,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4575,8 +4588,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4585,8 +4598,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "clap", @@ -4597,8 +4610,8 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "cc", @@ -4610,8 +4623,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -4619,16 +4632,16 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "mesh-llm-native-runtime", ] [[package]] name = "mesh-llm-host-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "argon2", @@ -4650,7 +4663,6 @@ dependencies = [ "http", "http-body-util", "httparse", - "if-addrs", "iroh", "json5", "keyring", @@ -4698,6 +4710,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "skippy-coordinator", + "skippy-ffi", "skippy-protocol", "skippy-runtime", "skippy-server", @@ -4720,8 +4733,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", "base64", @@ -4742,8 +4755,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "serde", @@ -4753,8 +4766,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-types", @@ -4767,8 +4780,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4784,8 +4797,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4795,6 +4808,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "zip", @@ -4802,8 +4816,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "hex", @@ -4815,16 +4829,16 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4846,8 +4860,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4861,8 +4875,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4872,8 +4886,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "chrono", @@ -4895,8 +4909,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "serde", @@ -4906,13 +4920,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-mixture-of-agents" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5038,8 +5052,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5049,8 +5063,8 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5067,8 +5081,8 @@ dependencies = [ [[package]] name = "model-package" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "bytes", @@ -5087,16 +5101,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "model-artifact", @@ -5815,8 +5829,8 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openai-frontend" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "axum", @@ -7368,9 +7382,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64", @@ -7401,9 +7415,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -8153,8 +8167,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "blake3", @@ -8163,29 +8177,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "skippy-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "prost", "prost-build", @@ -8195,8 +8209,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "libc", @@ -8209,9 +8223,10 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ + "ahash", "anyhow", "async-trait", "axum", @@ -8237,8 +8252,8 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -10465,8 +10480,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.62.2", - "windows-core 0.62.2", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] diff --git a/Justfile b/Justfile index a2fa408e7..2d76f1a7b 100644 --- a/Justfile +++ b/Justfile @@ -725,7 +725,7 @@ bump-relay-version version: cargo update -p buzz-relay echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" -# Open or update the desktop release PR (signed desktop app) +# Open or update the desktop release PR from an immutable origin/main snapshot release-desktop *ARGS: #!/usr/bin/env bash set -euo pipefail @@ -735,7 +735,7 @@ release-desktop *ARGS: else VERSION="$ARG" fi - just _release-pr desktop "$VERSION" + scripts/prepare-desktop-release.sh "$VERSION" # Open or update the relay release PR (ghcr.io/block/buzz image) release-relay *ARGS: diff --git a/RELEASING.md b/RELEASING.md index 063b813e2..45f0f8638 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | +| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -31,7 +31,7 @@ just release-relay 0.4.0 scripts/mobile-release.sh candidate 0.5.0 ``` -Desktop and relay releases use metadata PRs. Mobile does not. Each +Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,12 +42,11 @@ or mobile GitHub Release. ### Desktop -1. **`just release-desktop`** runs locally on `main`, creates or updates a - `version-bump/` PR, bumps the desktop manifests, regenerates - lockfiles, and updates `CHANGELOG.md`. -2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v`. -3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and - publishes the desktop app for macOS and Linux. +1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR. +2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval. +3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs. +4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`. +5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions. ### Relay @@ -147,8 +146,8 @@ for distributable builds or builds from an immutable release tag. ## Manual Release Retry The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `v` tag. Select that tag in the ref picker and -provide the matching semver version without the `v` prefix. It cannot build +existing immutable `desktop-v` tag. Select that tag in the ref picker and +provide the matching semver version without the `desktop-v` prefix. It cannot build from `main` or another caller-selected source ref. Mobile intentionally has no branch or arbitrary-ref fallback. The private @@ -171,7 +170,7 @@ for the private pipeline contract. Desktop publishes two GitHub releases: -1. **`v`**: the user-facing release with installers. +1. **`desktop-v`**: the user-facing release with installers. 2. **`buzz-desktop-latest`**: the rolling auto-updater release. Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts @@ -186,7 +185,7 @@ The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel (`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and `.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `v` release. Intel users download the `_x64.dmg`. +the same `desktop-v` release. Intel users download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on diff --git a/VISION_PROJECTS.md b/VISION_PROJECTS.md index a44d7e05f..8601b8782 100644 --- a/VISION_PROJECTS.md +++ b/VISION_PROJECTS.md @@ -38,12 +38,42 @@ Branch protections live in the same event — `buzz-protect` tags. The relay enf Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly. -Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, zero custom kinds. +Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, no custom kind for the repo itself. NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable. --- +## One Project, Many Repos + +Real work spans repositories. The platform is a relay, a desktop app, and a mobile app — three repos, one project. Render one card per repo and they look like three unrelated things. + +Grouping is the one forge semantic that per-repo tags cannot express, and it's worth being precise about why, because everything else here deliberately avoids a custom kind. + +Put membership in each `kind:30617` and a project spanning Alice's and Bob's repos needs *both* of them to publish a tag naming the group. Alice can't enroll Bob's repo — she can't sign for his key. Cross-owner grouping becomes impossible, and the project's own name, description, and channel end up scattered across events with no single writer and no deletion story: dropping a repo from the group would mean editing an event you don't control. + +So there is exactly one custom kind — [NIP-MP](docs/nips/NIP-MP.md), `kind:30621`. One signer, one replaceable event, all group state in one place: + +```json +{ + "kind": 30621, + "tags": [ + ["d", "platform"], + ["name", "Platform"], + ["a", "30617::buzz"], + ["a", "30617::buzz-infra"], + ["buzz-channel", ""], + ["buzz-visibility", "listed"] + ] +} +``` + +A project points at repos. That's all it does. The signer gets no authority over any member — no edit, no delete, no push, no admin. Adding Bob's repo to your project is your signed assertion that the two belong together, and it changes nothing about Bob's repo or who can push to it. Push policy reads the repo's own event, never the project's. + +The cost is stated plainly: a third-party NIP-34 client sees the member repos individually and ignores the grouping. Nothing degrades — the repos are still standard, portable `kind:30617` events. And a repo in no project still renders on its own, exactly as before. + +--- + ## Branches as Channels A feature branch is a conversation. @@ -205,6 +235,7 @@ Standard kinds as substrate. Custom kinds only where genuinely novel. | **Workflows** | — | 46001-46012 | No NIP equivalent | | **Job dispatch** | — | 43001-43006 | Delegation trees | | **Project binding** | 30617 (NIP-34) | `buzz-` tags | Channel, visibility | +| **Multi-repo projects** | — | 30621 ([NIP-MP](docs/nips/NIP-MP.md)) | Cross-owner grouping is unexpressible in per-repo tags | | **Audit** | — | 48001 | Hash-chain tamper-evident log | If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol. @@ -221,6 +252,7 @@ If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patc | Blossom media storage (SHA-256, S3) | ✅ Ships today | | Approval gates | 🚧 Infrastructure exists; executor wiring in progress | | Project binding (kind:30617 + `buzz-` tags) | 📋 Designed | +| Multi-repo projects (kind:30621, [NIP-MP](docs/nips/NIP-MP.md)) | 📋 Designed | | Git hosting (smart HTTP + NIP-34) | ✅ Ships today | | Merge coordinator | 📋 Designed | | NIP-34 issues (kind:1621) | 📋 Designed | diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index d1e005cbc..158477c0a 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -19,6 +19,7 @@ //! //! `AcpClient` is NOT Clone — ownership moves out on claim and back on return. +use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -800,6 +801,9 @@ pub enum IdleSwitchResult { /// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s. const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(3_000); +/// Short, single-attempt timeout for best-effort exact truncated-thread counts. +const CONTEXT_COUNT_TIMEOUT: Duration = Duration::from_millis(500); + /// Delay between the first failed context fetch and the single retry. const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500); @@ -2600,7 +2604,14 @@ async fn fetch_conversation_context( let last_event = batch.events.last()?; let tags = crate::queue::parse_thread_tags(&last_event.event); if let Some(root_id) = tags.root_event_id { - return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await; + return fetch_thread_context( + batch.channel_id, + &root_id, + limit, + ctx.agent_keys.public_key(), + &ctx.rest_client, + ) + .await; } // DM non-reply: fetch recent conversation history. @@ -2762,12 +2773,48 @@ async fn fetch_prompt_profile_lookup( } /// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag. +/// +/// The reply query intentionally requests one more reply than the configured +/// display window. That sentinel event lets the prompt say `N of M, truncated` +/// when the relay has more thread history, instead of reporting the capped page +/// as the total. When the window is full, a best-effort `/count` attempts to +/// improve that lower-bound total; because it is a separate racy request, the +/// result is clamped to the sentinel-proven minimum. The query also asks for the +/// agent's newest reply separately so the next prompt can include the agent's +/// own prior turn even in busy threads where the recent-message window would +/// otherwise push it out. async fn fetch_thread_context( channel_id: Uuid, root_event_id: &str, limit: u32, + agent_pubkey: nostr::PublicKey, rest: &RestClient, ) -> Option { + fetch_thread_context_with( + channel_id, + root_event_id, + limit, + agent_pubkey, + |filters| async move { rest.query(&filters).await }, + |filters| async move { rest.count(&filters).await }, + ) + .await +} + +async fn fetch_thread_context_with( + channel_id: Uuid, + root_event_id: &str, + limit: u32, + agent_pubkey: nostr::PublicKey, + query: Query, + count: Count, +) -> Option +where + Query: Fn(Vec) -> QueryFut, + QueryFut: std::future::Future>, + Count: Fn(Vec) -> CountFut, + CountFut: std::future::Future>, +{ use nostr::{Alphabet, SingleLetterTag}; // Defense-in-depth: validate hex event ID. @@ -2786,7 +2833,8 @@ async fn fetch_thread_context( let h_tag = SingleLetterTag::lowercase(Alphabet::H); let ch_str = channel_id.to_string(); - // Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel. + // Three filters: (1) root event by ID, (2) recent replies with #e=root + + // #h=channel plus a sentinel, and (3) the agent's newest reply for pinning. let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?); let replies_filter = nostr::Filter::new() .kinds([ @@ -2795,16 +2843,23 @@ async fn fetch_thread_context( ]) .custom_tags(e_tag, [root_event_id]) .custom_tags(h_tag, [ch_str.as_str()]) - .limit(limit as usize); + .limit(limit.saturating_add(1) as usize); + let agent_reply_filter = replies_filter.clone().author(agent_pubkey).limit(1); - fetch_with_retry(|| async { + let context = fetch_with_retry(|| async { match timeout( CONTEXT_FETCH_TIMEOUT, - rest.query(&[root_filter.clone(), replies_filter.clone()]), + query(vec![ + root_filter.clone(), + replies_filter.clone(), + agent_reply_filter.clone(), + ]), ) .await { - Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id), + Ok(Ok(json)) => { + parse_nostr_thread_response_with_meta(json, root_event_id, limit, &agent_pubkey) + } Ok(Err(e)) => { tracing::warn!( channel_id = %channel_id, @@ -2823,7 +2878,75 @@ async fn fetch_thread_context( } } }) - .await + .await; + + let mut parsed = context?; + + if matches!( + parsed.context, + ConversationContext::Thread { + truncated: true, + .. + } + ) { + let replies_count_filter = replies_filter.clone().limit(0); + if let Some(total) = fetch_thread_total( + channel_id, + &replies_count_filter, + parsed.root_present, + &count, + ) + .await + { + if let ConversationContext::Thread { + total: context_total, + .. + } = &mut parsed.context + { + let sentinel_minimum = *context_total; + // `/count` is a separate best-effort request after the message + // query. If replies are deleted between the two, the exact count + // can fall below the already-proven sentinel minimum; never + // render impossible labels like `13 of 12 messages, truncated`. + *context_total = total.max(sentinel_minimum); + } + } + } + + Some(parsed.context) +} + +/// Best-effort exact thread size for truncated context labels. +async fn fetch_thread_total( + channel_id: Uuid, + replies_filter: &nostr::Filter, + root_present: bool, + count: &Count, +) -> Option +where + Count: Fn(Vec) -> CountFut, + CountFut: std::future::Future>, +{ + let replies_count = + match timeout(CONTEXT_COUNT_TIMEOUT, count(vec![replies_filter.clone()])).await { + Ok(Ok(json)) => json.get("count").and_then(|v| v.as_u64())?, + Ok(Err(e)) => { + tracing::debug!( + channel_id = %channel_id, + "thread context count failed; using sentinel minimum: {e}" + ); + return None; + } + Err(_) => { + tracing::debug!( + channel_id = %channel_id, + "thread context count timed out; using sentinel minimum" + ); + return None; + } + }; + + Some(replies_count as usize + usize::from(root_present)) } /// Fetch DM context via Nostr query: recent messages in channel by `#h` tag. @@ -2976,48 +3099,110 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { /// Parse a Nostr query response (array of events) into thread context. /// -/// Separates the root event (matching `root_event_id`) from replies, sorts -/// chronologically by `created_at`. +/// Separates the root event (matching `root_event_id`) from replies, keeps the +/// newest `limit` replies returned by the sentinel query, then sorts the +/// displayed window chronologically for the prompt. If the agent's newest reply +/// is outside that window, keep it instead of the oldest displayed reply so the +/// next prompt always includes the agent's most recent prior turn. +#[cfg(test)] fn parse_nostr_thread_response( json: serde_json::Value, root_event_id: &str, + limit: u32, + agent_pubkey: &nostr::PublicKey, ) -> Option { + parse_nostr_thread_response_with_meta(json, root_event_id, limit, agent_pubkey) + .map(|parsed| parsed.context) +} + +struct ParsedThreadContext { + context: ConversationContext, + root_present: bool, +} + +fn parse_nostr_thread_response_with_meta( + json: serde_json::Value, + root_event_id: &str, + limit: u32, + agent_pubkey: &nostr::PublicKey, +) -> Option { let events = json.as_array()?; + let agent_pubkey_hex = agent_pubkey.to_hex(); let mut root_msg = None; let mut reply_msgs = Vec::new(); + let mut seen_reply_ids = HashSet::new(); for ev in events { let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or(""); if let Some(msg) = json_to_context_message(ev) { if ev_id == root_event_id { root_msg = Some(msg); - } else { + } else if seen_reply_ids.insert(ev_id.to_string()) { + let is_agent = msg.pubkey.eq_ignore_ascii_case(&agent_pubkey_hex); reply_msgs.push(( + ev_id.to_string(), ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + is_agent, msg, )); } } } - // Sort replies chronologically. - reply_msgs.sort_by_key(|(ts, _)| *ts); + let root_present = root_msg.is_some(); + let fetched_total = reply_msgs.len() + usize::from(root_present); + let newest_agent_reply = reply_msgs + .iter() + .filter(|(_, _, is_agent, _)| *is_agent) + .max_by_key(|(_, ts, _, _)| *ts) + .cloned(); + + let truncated = reply_msgs.len() > limit as usize; + if truncated { + // The relay returns limited REQ results newest-first. Sort explicitly so + // the sentinel we drop is the oldest reply in the fetched window, not an + // arbitrary last element if the HTTP bridge ever changes iteration order. + reply_msgs.sort_by_key(|(_, ts, _, _)| Reverse(*ts)); + reply_msgs.truncate(limit as usize); + } + + if let Some(agent_reply) = newest_agent_reply { + let agent_reply_already_displayed = + reply_msgs.iter().any(|(id, _, _, _)| *id == agent_reply.0); + if !agent_reply_already_displayed { + reply_msgs.sort_by_key(|(_, ts, _, _)| *ts); + if let Some(oldest) = reply_msgs.first_mut() { + *oldest = agent_reply; + } + } + } + + // Sort displayed replies chronologically. + reply_msgs.sort_by_key(|(_, ts, _, _)| *ts); let mut messages = Vec::new(); if let Some(root) = root_msg { messages.push(root); } - messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg)); + messages.extend(reply_msgs.into_iter().map(|(_, _, _, msg)| msg)); - let total = messages.len(); if messages.is_empty() { return None; } - Some(ConversationContext::Thread { - messages, - total, - truncated: false, // query returns all within limit + let total = if truncated { + fetched_total // all distinct fetched replies plus the root are proven visible history + } else { + messages.len() + }; + + Some(ParsedThreadContext { + context: ConversationContext::Thread { + messages, + total, + truncated, + }, + root_present, }) } @@ -4204,6 +4389,572 @@ mod tests { assert!(parse_dm_response(json, 12).is_none()); } + #[test] + fn test_parse_nostr_thread_response_marks_query_window_truncated() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let agent_hex = agent.public_key().to_hex(); + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": agent_hex, + "content": "newest agent reply", + "created_at": 4000 + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "humanpub", + "content": "middle reply", + "created_at": 3000 + }, + { + "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "oldpub", + "content": "sentinel omitted reply", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert_eq!(messages.len(), 3); // root + 2 displayed replies + assert_eq!(total, 4); // root + displayed replies + sentinel + assert!(truncated); + assert_eq!(messages[0].content, "root"); + assert_eq!(messages[1].content, "middle reply"); + assert_eq!(messages[2].content, "newest agent reply"); + assert!(messages + .iter() + .all(|msg| msg.content != "sentinel omitted reply")); + } + _ => panic!("expected Thread context"), + } + } + + #[test] + fn test_parse_nostr_thread_response_not_truncated_below_limit() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "replypub", + "content": "reply", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert_eq!(messages.len(), 2); + assert_eq!(total, 2); + assert!(!truncated); + } + _ => panic!("expected Thread context"), + } + } + + #[test] + fn test_parse_nostr_thread_response_keeps_agent_reply_outside_recent_window() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let agent_hex = agent.public_key().to_hex(); + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "humanpub", + "content": "newer human reply", + "created_at": 5000 + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "humanpub", + "content": "middle human reply", + "created_at": 4000 + }, + { + "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "humanpub", + "content": "oldest displayed reply without agent pin", + "created_at": 3000 + }, + { + "id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "pubkey": agent_hex, + "content": "agent reply outside recent window", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { messages, .. } => { + assert_eq!(messages.len(), 3); // root + 2 displayed replies + assert_eq!(messages[0].content, "root"); + assert!(messages + .iter() + .any(|msg| msg.content == "agent reply outside recent window")); + assert!(messages + .iter() + .any(|msg| msg.content == "newer human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "oldest displayed reply without agent pin")); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_uses_exact_count_when_above_sentinel_minimum() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let agent_pubkey = agent.public_key(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent_pubkey, + move |filters| { + assert_thread_query_filters(&filters, channel_id, root_id, agent_pubkey, 3); + std::future::ready(Ok(json.clone())) + }, + move |filters| { + assert_thread_count_filter(&filters, channel_id, root_id); + std::future::ready(Ok(json!({ "count": 6 }))) + }, + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 7); // 6 replies + root + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_does_not_add_missing_root_to_exact_count() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 6 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 2); + assert_eq!(total, 6); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_clamps_count_below_sentinel_minimum() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 1 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 4); // root + displayed replies + sentinel minimum + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_preserves_sentinel_minimum_when_count_fails() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 4); // count failure leaves parser's sentinel minimum intact + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_deduplicates_and_pins_agent_reply() { + let agent = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newer human reply", + 5000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle human reply", + 4000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + &agent_hex, + "agent reply outside recent window", + 2000 + ), + // Same event as the separately fetched author-filtered result; the + // parser should deduplicate it before pinning. + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + &agent_hex, + "agent reply outside recent window", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 3 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(total, 4); + assert_eq!(messages.len(), 3); + assert_eq!( + messages + .iter() + .filter(|msg| msg.content == "agent reply outside recent window") + .count(), + 1, + "separate agent-reply query must not duplicate the same event" + ); + assert!(messages + .iter() + .any(|msg| msg.content == "newer human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_uses_distinct_fetched_replies_as_minimum() { + let agent = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest human reply", + 5000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle human reply", + 4000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel human reply", + 3000 + ), + thread_event( + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + &agent_hex, + "older distinct agent reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!( + total, 5, + "root plus all four distinct fetched replies prove the lower bound" + ); + assert!(messages + .iter() + .any(|msg| msg.content == "older distinct agent reply")); + assert!(messages + .iter() + .any(|msg| msg.content == "newest human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "sentinel human reply")); + } + _ => panic!("expected Thread context"), + } + } + + fn assert_thread_query_filters( + filters: &[nostr::Filter], + channel_id: Uuid, + root_id: &str, + agent_pubkey: nostr::PublicKey, + reply_limit: u64, + ) { + assert_eq!( + filters.len(), + 3, + "root, recent replies, and agent reply filters" + ); + + let root = serde_json::to_value(&filters[0]).expect("serialize root filter"); + assert_eq!(root.get("ids"), Some(&json!([root_id]))); + assert!(root.get("limit").is_none()); + + let replies = serde_json::to_value(&filters[1]).expect("serialize replies filter"); + assert_eq!(replies.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(replies.get("#e"), Some(&json!([root_id]))); + assert_eq!(replies.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(replies.get("limit"), Some(&json!(reply_limit))); + assert!(replies.get("authors").is_none()); + + let agent = serde_json::to_value(&filters[2]).expect("serialize agent filter"); + assert_eq!(agent.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(agent.get("#e"), Some(&json!([root_id]))); + assert_eq!(agent.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(agent.get("authors"), Some(&json!([agent_pubkey.to_hex()]))); + assert_eq!(agent.get("limit"), Some(&json!(1))); + } + + fn assert_thread_count_filter(filters: &[nostr::Filter], channel_id: Uuid, root_id: &str) { + assert_eq!(filters.len(), 1, "count should query only matching replies"); + + let count = serde_json::to_value(&filters[0]).expect("serialize count filter"); + assert_eq!(count.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(count.get("#e"), Some(&json!([root_id]))); + assert_eq!(count.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(count.get("limit"), Some(&json!(0))); + assert!(count.get("ids").is_none()); + assert!(count.get("authors").is_none()); + } + + fn thread_event(id: &str, pubkey: &str, content: &str, created_at: u64) -> serde_json::Value { + json!({ + "id": id, + "pubkey": pubkey, + "content": content, + "created_at": created_at + }) + } + #[test] fn test_json_to_context_message_integer_timestamp() { let obj = json!({ diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c8312cc61..aea5cee07 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -405,6 +405,19 @@ impl RestClient { .map_err(|e| RelayError::Http(e.to_string())) } + /// Count events via the HTTP bridge: `POST /count` with NIP-98 auth. + /// + /// Accepts a slice of `nostr::Filter` (serialized as JSON array). + /// Returns the bridge response as a `serde_json::Value` (usually `{ "count": n }`). + pub async fn count(&self, filters: &[nostr::Filter]) -> Result { + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; + let resp = self.bridge_post("/count", &body_bytes).await?; + resp.json() + .await + .map_err(|e| RelayError::Http(e.to_string())) + } + /// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth. /// /// The event must already be signed. Returns the relay response JSON. diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 01f78a2d4..41bdc3b9e 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -84,8 +84,8 @@ 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.73.1", 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.73.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", 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.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-relay/examples/mesh_agent_e2e.rs index b6f723f35..345ca4c74 100644 --- a/crates/buzz-relay/examples/mesh_agent_e2e.rs +++ b/crates/buzz-relay/examples/mesh_agent_e2e.rs @@ -278,7 +278,9 @@ async fn agent_chat_in_isolated_home( .env("OPENAI_COMPAT_API_KEY", "buzz-mesh-local") .env("OPENAI_COMPAT_API", "chat") .env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096") - .env("BUZZ_AGENT_THINKING_EFFORT", "none") + // No BUZZ_AGENT_THINKING_EFFORT: apply_relay_mesh_env() deliberately + // leaves it unset so each model's chat template picks its own default. + // Pinning a value here would test a config the product does not ship. .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c79ef1bf9..b86406d9b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -119,6 +119,7 @@ export default defineConfig({ "**/nostr-bind.spec.ts", "**/mobile-pairing-qr.spec.ts", "**/profile-nsec-reveal.spec.ts", + "**/profile-backup-settings.spec.ts", "**/signout-confirmation.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d1b11b289..325eb9aa6 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -75,6 +75,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.4", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -1032,6 +1045,7 @@ dependencies = [ "ed25519-dalek", "flate2", "futures-util", + "getrandom 0.2.17", "hex", "image", "infer", @@ -1087,6 +1101,7 @@ dependencies = [ "url", "user-idle", "uuid", + "webkit2gtk", "window-vibrancy", "windows-sys 0.61.2", "zeroize", @@ -1510,7 +1525,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1747,7 +1762,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -2151,7 +2166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -3101,8 +3116,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3847,7 +3862,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.5.3", + "windows-registry 0.6.1", ] [[package]] @@ -3862,7 +3877,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4938,8 +4953,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "mesh-llm-client", @@ -4948,8 +4963,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4959,13 +4974,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-llm-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4996,8 +5011,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5012,8 +5027,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5022,8 +5037,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "clap", @@ -5034,8 +5049,8 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "cc", @@ -5047,8 +5062,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -5056,16 +5071,16 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "mesh-llm-native-runtime", ] [[package]] name = "mesh-llm-host-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "argon2", @@ -5087,7 +5102,6 @@ dependencies = [ "http", "http-body-util", "httparse", - "if-addrs", "iroh", "json5", "keyring", @@ -5135,6 +5149,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "skippy-coordinator", + "skippy-ffi", "skippy-protocol", "skippy-runtime", "skippy-server", @@ -5157,8 +5172,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", "base64 0.22.1", @@ -5179,8 +5194,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "serde", @@ -5190,8 +5205,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-types", @@ -5204,8 +5219,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5221,8 +5236,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5232,6 +5247,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "zip 2.4.2", @@ -5239,8 +5255,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "hex", @@ -5252,16 +5268,16 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5283,8 +5299,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5298,8 +5314,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5309,8 +5325,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "chrono", @@ -5332,8 +5348,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "serde", @@ -5343,13 +5359,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-mixture-of-agents" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5421,8 +5437,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5432,8 +5448,8 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5450,8 +5466,8 @@ dependencies = [ [[package]] name = "model-package" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "bytes", @@ -5470,16 +5486,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "model-artifact", @@ -6133,7 +6149,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.118", @@ -6507,8 +6523,8 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "axum", @@ -6701,7 +6717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7411,7 +7427,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools", "log", "multimap", @@ -9040,8 +9056,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "blake3", @@ -9050,29 +9066,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "skippy-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "prost", "prost-build", @@ -9082,8 +9098,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "libc", @@ -9096,9 +9112,10 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ + "ahash", "anyhow", "async-trait", "axum", @@ -9124,8 +9141,8 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -10152,7 +10169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -11788,7 +11805,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12417,8 +12434,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 90f90870f..6f3c03c5a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -40,6 +40,10 @@ keyring = { version = "3.6.3", default-features = false, features = ["sync-secre # connection is dropped, which the plugin does immediately. Default features # keep the pure-Rust zbus backend, matching the plugin (no libdbus needed). notify-rust = "4" +# Enable getUserMedia in the WebKitGTK webview (see src/linux_media.rs). Pinned +# to the exact version wry links so both resolve to one webkit2gtk-sys and we +# don't get duplicate symbols; bump in lockstep with wry. +webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.4", default-features = false } @@ -82,7 +86,10 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" toml = "0.8" -nostr = { version = "0.44", features = ["nip44"] } +nostr = { version = "0.44", features = ["nip44", "nip49"] } +# OS-entropy source for backup passphrase generation (already in the tree as a +# transitive dependency; pinned here for direct use). +getrandom = "0.2" zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } @@ -92,14 +99,14 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", 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", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", 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.74.0", 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.74.0", 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", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt b/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt new file mode 100644 index 000000000..9ac732fe3 --- /dev/null +++ b/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt @@ -0,0 +1,1296 @@ +aardvark +abandoned +abbreviate +abdomen +abhorrence +abiding +abnormal +abrasion +absorbing +abundant +abyss +academy +accountant +acetone +achiness +acid +acoustics +acquire +acrobat +actress +acuteness +aerosol +aesthetic +affidavit +afloat +afraid +aftershave +again +agency +aggressor +aghast +agitate +agnostic +agonizing +agreeing +aidless +aimlessly +ajar +alarmclock +albatross +alchemy +alfalfa +algae +aliens +alkaline +almanac +alongside +alphabet +already +also +altitude +aluminum +always +amazingly +ambulance +amendment +amiable +ammunition +amnesty +amoeba +amplifier +amuser +anagram +anchor +android +anesthesia +angelfish +animal +anklet +announcer +anonymous +answer +antelope +anxiety +anyplace +aorta +apartment +apnea +apostrophe +apple +apricot +aquamarine +arachnid +arbitrate +ardently +arena +argument +aristocrat +armchair +aromatic +arrowhead +arsonist +artichoke +asbestos +ascend +aseptic +ashamed +asinine +asleep +asocial +asparagus +astronaut +asymmetric +atlas +atmosphere +atom +atrocious +attic +atypical +auctioneer +auditorium +augmented +auspicious +automobile +auxiliary +avalanche +avenue +aviator +avocado +awareness +awhile +awkward +awning +awoke +axially +azalea +babbling +backpack +badass +bagpipe +bakery +balancing +bamboo +banana +barracuda +basket +bathrobe +bazooka +blade +blender +blimp +blouse +blurred +boatyard +bobcat +body +bogusness +bohemian +boiler +bonnet +boots +borough +bossiness +bottle +bouquet +boxlike +breath +briefcase +broom +brushes +bubblegum +buckle +buddhist +buffalo +bullfrog +bunny +busboy +buzzard +cabin +cactus +cadillac +cafeteria +cage +cahoots +cajoling +cakewalk +calculator +camera +canister +capsule +carrot +cashew +cathedral +caucasian +caviar +ceasefire +cedar +celery +cement +census +ceramics +cesspool +chalkboard +cheesecake +chimney +chlorine +chopsticks +chrome +chute +cilantro +cinnamon +circle +cityscape +civilian +clay +clergyman +clipboard +clock +clubhouse +coathanger +cobweb +coconut +codeword +coexistent +coffeecake +cognitive +cohabitate +collarbone +computer +confetti +copier +cornea +cosmetics +cotton +couch +coverless +coyote +coziness +crawfish +crewmember +crib +croissant +crumble +crystal +cubical +cucumber +cuddly +cufflink +cuisine +culprit +cup +curry +cushion +cuticle +cybernetic +cyclist +cylinder +cymbal +cynicism +cypress +cytoplasm +dachshund +daffodil +dagger +dairy +dalmatian +dandelion +dartboard +dastardly +datebook +daughter +dawn +daytime +dazzler +dealer +debris +decal +dedicate +deepness +defrost +degree +dehydrator +deliverer +democrat +dentist +deodorant +depot +deranged +desktop +detergent +device +dexterity +diamond +dibs +dictionary +diffuser +digit +dilated +dimple +dinnerware +dioxide +diploma +directory +dishcloth +ditto +dividers +dizziness +doctor +dodge +doll +dominoes +donut +doorstep +dorsal +double +downstairs +dozed +drainpipe +dresser +driftwood +droppings +drum +dryer +dubiously +duckling +duffel +dugout +dumpster +duplex +durable +dustpan +dutiful +duvet +dwarfism +dwelling +dwindling +dynamite +dyslexia +eagerness +earlobe +easel +eavesdrop +ebook +eccentric +echoless +eclipse +ecosystem +ecstasy +edged +editor +educator +eelworm +eerie +effects +eggnog +egomaniac +ejection +elastic +elbow +elderly +elephant +elfishly +eliminator +elk +elliptical +elongated +elsewhere +elusive +elves +emancipate +embroidery +emcee +emerald +emission +emoticon +emperor +emulate +enactment +enchilada +endorphin +energy +enforcer +engine +enhance +enigmatic +enjoyably +enlarged +enormous +enquirer +enrollment +ensemble +entryway +enunciate +envoy +enzyme +epidemic +equipment +erasable +ergonomic +erratic +eruption +escalator +eskimo +esophagus +espresso +essay +estrogen +etching +eternal +ethics +etiquette +eucalyptus +eulogy +euphemism +euthanize +evacuation +evergreen +evidence +evolution +exam +excerpt +exerciser +exfoliate +exhale +exist +exorcist +explode +exquisite +exterior +exuberant +fabric +factory +faded +failsafe +falcon +family +fanfare +fasten +faucet +favorite +feasibly +february +federal +feedback +feigned +feline +femur +fence +ferret +festival +fettuccine +feudalist +feverish +fiberglass +fictitious +fiddle +figurine +fillet +finalist +fiscally +fixture +flashlight +fleshiness +flight +florist +flypaper +foamless +focus +foggy +folksong +fondue +footpath +fossil +fountain +fox +fragment +freeway +fridge +frosting +fruit +fryingpan +gadget +gainfully +gallstone +gamekeeper +gangway +garlic +gaslight +gathering +gauntlet +gearbox +gecko +gem +generator +geographer +gerbil +gesture +getaway +geyser +ghoulishly +gibberish +giddiness +giftshop +gigabyte +gimmick +giraffe +giveaway +gizmo +glasses +gleeful +glisten +glove +glucose +glycerin +gnarly +gnomish +goatskin +goggles +goldfish +gong +gooey +gorgeous +gosling +gothic +gourmet +governor +grape +greyhound +grill +groundhog +grumbling +guacamole +guerrilla +guitar +gullible +gumdrop +gurgling +gusto +gutless +gymnast +gynecology +gyration +habitat +hacking +haggard +haiku +halogen +hamburger +handgun +happiness +hardhat +hastily +hatchling +haughty +hazelnut +headband +hedgehog +hefty +heinously +helmet +hemoglobin +henceforth +herbs +hesitation +hexagon +hubcap +huddling +huff +hugeness +hullabaloo +human +hunter +hurricane +hushing +hyacinth +hybrid +hydrant +hygienist +hypnotist +ibuprofen +icepack +icing +iconic +identical +idiocy +idly +igloo +ignition +iguana +illuminate +imaging +imbecile +imitator +immigrant +imprint +iodine +ionosphere +ipad +iphone +iridescent +irksome +iron +irrigation +island +isotope +issueless +italicize +itemizer +itinerary +itunes +ivory +jabbering +jackrabbit +jaguar +jailhouse +jalapeno +jamboree +janitor +jarring +jasmine +jaundice +jawbreaker +jaywalker +jazz +jealous +jeep +jelly +jeopardize +jersey +jetski +jezebel +jiffy +jigsaw +jingling +jobholder +jockstrap +jogging +john +joinable +jokingly +journal +jovial +joystick +jubilant +judiciary +juggle +juice +jujitsu +jukebox +jumpiness +junkyard +juror +justifying +juvenile +kabob +kamikaze +kangaroo +karate +kayak +keepsake +kennel +kerosene +ketchup +khaki +kickstand +kilogram +kimono +kingdom +kiosk +kissing +kite +kleenex +knapsack +kneecap +knickers +koala +krypton +laboratory +ladder +lakefront +lantern +laptop +laryngitis +lasagna +latch +laundry +lavender +laxative +lazybones +lecturer +leftover +leggings +leisure +lemon +length +leopard +leprechaun +lettuce +leukemia +levers +lewdness +liability +library +licorice +lifeboat +lightbulb +likewise +lilac +limousine +lint +lioness +lipstick +liquid +listless +litter +liverwurst +lizard +llama +luau +lubricant +lucidity +ludicrous +luggage +lukewarm +lullaby +lumberjack +lunchbox +luridness +luscious +luxurious +lyrics +macaroni +maestro +magazine +mahogany +maimed +majority +makeover +malformed +mammal +mango +mapmaker +marbles +massager +matchstick +maverick +maximum +mayonnaise +moaning +mobilize +moccasin +modify +moisture +molecule +momentum +monastery +moonshine +mortuary +mosquito +motorcycle +mousetrap +movie +mower +mozzarella +muckiness +mudflow +mugshot +mule +mummy +mundane +muppet +mural +mustard +mutation +myriad +myspace +myth +nail +namesake +nanosecond +napkin +narrator +nastiness +natives +nautically +navigate +nearest +nebula +nectar +nefarious +negotiator +neither +nemesis +neoliberal +nephew +nervously +nest +netting +neuron +nevermore +nextdoor +nicotine +niece +nimbleness +nintendo +nirvana +nuclear +nugget +nuisance +nullify +numbing +nuptials +nursery +nutcracker +nylon +oasis +oat +obediently +obituary +object +obliterate +obnoxious +observer +obtain +obvious +occupation +oceanic +octopus +ocular +office +oftentimes +oiliness +ointment +older +olympics +omissible +omnivorous +oncoming +onion +onlooker +onstage +onward +onyx +oomph +opaquely +opera +opium +opossum +opponent +optical +opulently +oscillator +osmosis +ostrich +otherwise +ought +outhouse +ovation +oven +owlish +oxford +oxidize +oxygen +oyster +ozone +pacemaker +padlock +pageant +pajamas +palm +pamphlet +pantyhose +paprika +parakeet +passport +patio +pauper +pavement +payphone +pebble +peculiarly +pedometer +pegboard +pelican +penguin +peony +pepperoni +peroxide +pesticide +petroleum +pewter +pharmacy +pheasant +phonebook +phrasing +physician +plank +pledge +plotted +plug +plywood +pneumonia +podiatrist +poetic +pogo +poison +poking +policeman +poncho +popcorn +porcupine +postcard +poultry +powerboat +prairie +pretzel +princess +propeller +prune +pry +pseudo +psychopath +publisher +pucker +pueblo +pulley +pumpkin +punchbowl +puppy +purse +pushup +putt +puzzle +pyramid +python +quarters +quesadilla +quilt +quote +racoon +radish +ragweed +railroad +rampantly +rancidity +rarity +raspberry +ravishing +rearrange +rebuilt +receipt +reentry +refinery +register +rehydrate +reimburse +rejoicing +rekindle +relic +remote +renovator +reopen +reporter +request +rerun +reservoir +retriever +reunion +revolver +rewrite +rhapsody +rhetoric +rhino +rhubarb +rhyme +ribbon +riches +ridden +rigidness +rimmed +riptide +riskily +ritzy +riverboat +roamer +robe +rocket +romancer +ropelike +rotisserie +roundtable +royal +rubber +rudderless +rugby +ruined +rulebook +rummage +running +rupture +rustproof +sabotage +sacrifice +saddlebag +saffron +sainthood +saltshaker +samurai +sandworm +sapphire +sardine +sassy +satchel +sauna +savage +saxophone +scarf +scenario +schoolbook +scientist +scooter +scrapbook +sculpture +scythe +secretary +sedative +segregator +seismology +selected +semicolon +senator +septum +sequence +serpent +sesame +settler +severely +shack +shelf +shirt +shovel +shrimp +shuttle +shyness +siamese +sibling +siesta +silicon +simmering +singles +sisterhood +sitcom +sixfold +sizable +skateboard +skeleton +skies +skulk +skylight +slapping +sled +slingshot +sloth +slumbering +smartphone +smelliness +smitten +smokestack +smudge +snapshot +sneezing +sniff +snowsuit +snugness +speakers +sphinx +spider +splashing +sponge +sprout +spur +spyglass +squirrel +statue +steamboat +stingray +stopwatch +strawberry +student +stylus +suave +subway +suction +suds +suffocate +sugar +suitcase +sulphur +superstore +surfer +sushi +swan +sweatshirt +swimwear +sword +sycamore +syllable +symphony +synagogue +syringes +systemize +tablespoon +taco +tadpole +taekwondo +tagalong +takeout +tallness +tamale +tanned +tapestry +tarantula +tastebud +tattoo +tavern +thaw +theater +thimble +thorn +throat +thumb +thwarting +tiara +tidbit +tiebreaker +tiger +timid +tinsel +tiptoeing +tirade +tissue +tractor +tree +tripod +trousers +trucks +tryout +tubeless +tuesday +tugboat +tulip +tumbleweed +tupperware +turtle +tusk +tutorial +tuxedo +tweezers +twins +tyrannical +ultrasound +umbrella +umpire +unarmored +unbuttoned +uncle +underwear +unevenness +unflavored +ungloved +unhinge +unicycle +unjustly +unknown +unlocking +unmarked +unnoticed +unopened +unpaved +unquenched +unroll +unscrewing +untied +unusual +unveiled +unwrinkled +unyielding +unzip +upbeat +upcountry +update +upfront +upgrade +upholstery +upkeep +upload +uppercut +upright +upstairs +uptown +upwind +uranium +urban +urchin +urethane +urgent +urologist +username +usher +utensil +utility +utmost +utopia +utterance +vacuum +vagrancy +valuables +vanquished +vaporizer +varied +vaseline +vegetable +vehicle +velcro +vendor +vertebrae +vestibule +veteran +vexingly +vicinity +videogame +viewfinder +vigilante +village +vinegar +violin +viperfish +virus +visor +vitamins +vivacious +vixen +vocalist +vogue +voicemail +volleyball +voucher +voyage +vulnerable +waffle +wagon +wakeup +walrus +wanderer +wasp +water +waving +wheat +whisper +wholesaler +wick +widow +wielder +wifeless +wikipedia +wildcat +windmill +wipeout +wired +wishbone +wizardry +wobbliness +wolverine +womb +woolworker +workbasket +wound +wrangle +wreckage +wristwatch +wrongdoing +xerox +xylophone +yacht +yahoo +yard +yearbook +yesterday +yiddish +yield +yo-yo +yodel +yogurt +yuppie +zealot +zebra +zeppelin +zestfully +zigzagged +zillion +zipping +zirconium +zodiac +zombie +zookeeper +zucchini diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs index 806f58d73..ded14679c 100644 --- a/desktop/src-tauri/src/commands/export_util.rs +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -1,16 +1,14 @@ use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; -/// Show a save-file dialog with a custom filter and write `data` to the chosen -/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the -/// user cancelled the dialog. -pub async fn save_bytes_with_dialog( +/// Show a save-file dialog with a custom filter and return the chosen path, +/// or `None` when the user cancelled. Selection only — no write. +pub async fn pick_save_path( app: &AppHandle, suggested_filename: &str, filter_name: &str, extensions: &[&str], - data: &[u8], -) -> Result { +) -> Result, String> { let (tx, rx) = tokio::sync::oneshot::channel(); app.dialog() .file() @@ -23,12 +21,34 @@ pub async fn save_bytes_with_dialog( let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?; let file_path = match selected { Some(p) => p, - None => return Ok(false), + None => return Ok(None), }; let dest = file_path .as_path() .ok_or_else(|| "Save dialog returned an invalid path".to_string())?; + Ok(Some(dest.to_path_buf())) +} + +/// Show a save-file dialog with a custom filter and write `data` to the chosen +/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the +/// user cancelled the dialog. +/// +/// NOT for secrets: the write is plain `std::fs::write` (no atomic commit, no +/// 0o600). Secret exports go through `pick_save_path` + +/// `key_backup::write_backup_file`. +pub async fn save_bytes_with_dialog( + app: &AppHandle, + suggested_filename: &str, + filter_name: &str, + extensions: &[&str], + data: &[u8], +) -> Result { + let dest = match pick_save_path(app, suggested_filename, filter_name, extensions).await? { + Some(p) => p, + None => return Ok(false), + }; + std::fs::write(dest, data).map_err(|e| format!("Failed to write file: {e}"))?; Ok(true) diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2840c0ade..142e3bac8 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -194,6 +194,143 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result { .map_err(|error| format!("encode nsec: {error}")) } +/// Generate a passphrase for a new encrypted backup (EFF short wordlist, OS +/// entropy). `words` is clamped to the range allowed by `key_backup`; +/// `separator` joins the words (defaults to a space). +#[tauri::command] +pub fn generate_backup_passphrase( + words: Option, + separator: Option, +) -> Result { + crate::key_backup::generate_passphrase( + words.map_or(crate::key_backup::DEFAULT_PASSPHRASE_WORDS, |w| w as usize), + separator.as_deref().unwrap_or(" "), + ) +} + +/// Core of [`create_ncryptsec_backup`], factored so tests can drive it with a +/// bare `AppState` + temp dir (and a fast scrypt tier) without an `AppHandle`. +pub(crate) fn create_backup_with_log_n( + state: &AppState, + password: &str, + log_n: u8, +) -> Result { + if password.chars().count() < crate::key_backup::MIN_PASSPHRASE_LEN { + return Err(format!( + "passphrase must be at least {} characters", + crate::key_backup::MIN_PASSPHRASE_LEN + )); + } + + // Serialize against import_identity/persist_current_identity: the blob + // must be derived from — and persisted for — one stable identity. Also + // caps KDF concurrency at one. + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + + // Recovery mode (lost/locked) → Err, same gate as signing. + let keys = state.signing_keys()?; + + crate::key_backup::create_backup_blob(&keys, password, log_n) +} + +/// Create a NIP-49 backup of the live identity in memory. +/// +/// Encrypts under `password`, decrypt-verifies the fresh blob against the live +/// pubkey, and returns the `ncryptsec1…` string for the native save flow. The +/// body runs under `identity_mutation`, so identity changes cannot race the KDF. +#[tauri::command] +pub async fn create_ncryptsec_backup( + password: String, + app_handle: tauri::AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let state = app_handle.state::(); + create_backup_with_log_n(&state, &password, crate::key_backup::BACKUP_LOG_N) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupVerification { + pub pubkey: String, + pub npub: String, + pub matches_current_identity: bool, +} + +fn verify_ncryptsec_backup_inner( + state: &AppState, + ncryptsec: &str, + password: &str, +) -> Result { + let keys = crate::key_backup::decrypt_ncryptsec(ncryptsec, password)?; + let pubkey = keys.public_key(); + let current = state.signing_keys()?.public_key(); + Ok(BackupVerification { + pubkey: pubkey.to_hex(), + npub: pubkey + .to_bech32() + .map_err(|e| format!("encode backup identity: {e}"))?, + matches_current_identity: pubkey == current, + }) +} + +/// Decrypt and validate a NIP-49 backup without exposing its secret key. +#[tauri::command] +pub async fn verify_ncryptsec_backup( + ncryptsec: String, + password: String, + app_handle: tauri::AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let state = app_handle.state::(); + verify_ncryptsec_backup_inner(&state, &ncryptsec, &password) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path. +/// +/// The input must parse as a structurally valid NIP-49 payload. The dialog is +/// selection-only; the write uses secret-file semantics (atomic + 0o600). +/// Never mutates canonical app state. Returns the chosen path, or `None` when +/// the user cancelled. +#[tauri::command] +pub async fn save_ncryptsec_copy( + ncryptsec: String, + app_handle: tauri::AppHandle, +) -> Result, String> { + // Reject anything that is not a valid encrypted-key blob — this command + // must not become a generic file writer. + crate::key_backup::parse_ncryptsec(&ncryptsec)?; + let normalized = ncryptsec.trim().to_string(); + + let dest = match crate::commands::export_util::pick_save_path( + &app_handle, + crate::key_backup::BACKUP_FILE_NAME, + "Password-protected key backup", + &["ncryptsec"], + ) + .await? + { + Some(p) => p, + None => return Ok(None), + }; + + let dest_for_write = dest.clone(); + tokio::task::spawn_blocking(move || { + crate::key_backup::write_backup_file(&dest_for_write, &normalized) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + Ok(Some(dest.display().to_string())) +} + #[tauri::command] pub async fn import_identity( nsec: String, @@ -589,3 +726,7 @@ mod nostr_identity_binding_tests { assert_eq!(error, "expires_at is expired"); } } + +#[cfg(test)] +#[path = "identity_key_backup_tests.rs"] +mod identity_key_backup_tests; diff --git a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs new file mode 100644 index 000000000..c36af6687 --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs @@ -0,0 +1,139 @@ +use super::{create_backup_with_log_n, verify_ncryptsec_backup_inner}; +use crate::app_state::build_app_state; +use nostr::{Keys, ToBech32}; + +/// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered +/// once in key_backup_tests::round_trip_at_production_cost. +const FAST_LOG_N: u8 = 16; +const PASSWORD: &str = "correct horse battery"; + +#[test] +fn verification_returns_only_public_identity_and_match_status() { + let state = build_app_state(); + let backup = create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).unwrap(); + let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); + assert_eq!( + result.pubkey, + state.keys.lock().unwrap().public_key().to_hex() + ); + assert!(result.npub.starts_with("npub1")); + assert!(result.matches_current_identity); +} + +#[test] +fn verification_reports_valid_backup_for_a_different_identity() { + let state = build_app_state(); + let other = Keys::generate(); + let backup = crate::key_backup::create_backup_blob(&other, PASSWORD, FAST_LOG_N).unwrap(); + let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); + assert_eq!(result.pubkey, other.public_key().to_hex()); + assert!(!result.matches_current_identity); +} + +#[test] +fn verification_rejects_wrong_password() { + let state = build_app_state(); + let backup = + crate::key_backup::create_backup_blob(&Keys::generate(), PASSWORD, FAST_LOG_N).unwrap(); + assert_eq!( + verify_ncryptsec_backup_inner(&state, &backup, "wrong password").unwrap_err(), + "wrong backup password or damaged key backup" + ); +} + +#[test] +fn verification_accepts_maximum_supported_kdf_cost() { + let state = build_app_state(); + let backup = crate::key_backup::create_backup_blob( + &Keys::generate(), + PASSWORD, + crate::key_backup::MAX_VERIFY_LOG_N, + ) + .unwrap(); + verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); +} + +#[test] +fn verification_rejects_unsupported_kdf_cost_before_decryption() { + let state = build_app_state(); + let supported = + crate::key_backup::create_backup_blob(&Keys::generate(), PASSWORD, FAST_LOG_N).unwrap(); + let encrypted = crate::key_backup::parse_ncryptsec(&supported).unwrap(); + let mut payload = encrypted.as_vec(); + payload[1] = crate::key_backup::MAX_VERIFY_LOG_N + 1; + let unsupported = nostr::nips::nip49::EncryptedSecretKey::from_slice(&payload) + .unwrap() + .to_bech32() + .unwrap(); + + let err = verify_ncryptsec_backup_inner(&state, &unsupported, PASSWORD).unwrap_err(); + assert_eq!( + err, + format!( + "unsupported backup KDF cost: log_n {} exceeds maximum {}", + crate::key_backup::MAX_VERIFY_LOG_N + 1, + crate::key_backup::MAX_VERIFY_LOG_N + ) + ); +} + +#[test] +fn rejects_short_passphrase() { + let state = build_app_state(); + let err = create_backup_with_log_n(&state, "short", FAST_LOG_N).unwrap_err(); + assert!(err.contains("at least"), "{err}"); +} + +#[test] +fn recovery_mode_blocks_backup_creation() { + let state = build_app_state(); + + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).is_err(), + "lost identity must not be backed up" + ); + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + + state + .keyring_locked + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).is_err(), + "locked keyring must not be backed up" + ); +} + +/// Concurrent identity changes serialize with backup creation. +#[test] +fn concurrent_identity_swap_vs_backup_is_serialized() { + let state = std::sync::Arc::new(build_app_state()); + let key_a = state.keys.lock().unwrap().clone(); + let key_b = Keys::generate(); + + let swapper = { + let state = state.clone(); + let key_b = key_b.clone(); + std::thread::spawn(move || { + // Mirrors import_identity's locking: mutation guard held + // across the key swap. + let _guard = state.identity_mutation.lock().unwrap(); + *state.keys.lock().unwrap() = key_b; + }) + }; + + let backup = create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).unwrap(); + swapper.join().unwrap(); + + let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD) + .unwrap() + .public_key(); + assert!( + recovered == key_a.public_key() || recovered == key_b.public_key(), + "backup must match one coherent identity" + ); +} diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 305c54a20..998bc6e7d 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -5,12 +5,35 @@ use tauri::{AppHandle, Manager, State}; use crate::{app_state::AppState, mesh_llm, relay}; -#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] struct MeshSharingConfig { enabled: bool, + /// A fresh Share Compute request that must cross a process boundary before + /// it can start. Consumed before startup so an interrupted download is not + /// resumed on a later launch. + #[serde(default)] + start_on_next_launch: bool, model_id: String, max_vram_gb: Option, + /// Community relay where Share Compute was explicitly enabled. Older + /// configs predate community binding and restore against the active relay. + #[serde(default)] + relay_url: Option, +} + +fn pending_new_start_checkpoint(config: &MeshSharingConfig) -> MeshSharingConfig { + let mut checkpoint = config.clone(); + checkpoint.enabled = false; + checkpoint.start_on_next_launch = false; + checkpoint +} + +fn one_shot_restart_checkpoint(config: &MeshSharingConfig) -> MeshSharingConfig { + let mut checkpoint = config.clone(); + checkpoint.enabled = false; + checkpoint.start_on_next_launch = true; + checkpoint } fn mesh_sharing_config_path(app: &AppHandle) -> Result { @@ -89,8 +112,10 @@ fn sharing_config_from_request( .ok_or_else(|| "modelId is required for serve mode".to_string())?; Ok(MeshSharingConfig { enabled: true, + start_on_next_launch: false, model_id: model_id.to_string(), max_vram_gb: request.max_vram_gb, + relay_url: request.relay_url.clone(), }) } @@ -117,7 +142,7 @@ fn restart_to_share( app: &AppHandle, config: &MeshSharingConfig, ) -> CmdResult { - save_mesh_sharing_config(app, config)?; + save_mesh_sharing_config(app, &one_shot_restart_checkpoint(config))?; let status = restarting_share_status(config); app.request_restart(); Ok(status) @@ -133,7 +158,7 @@ fn buzz_mesh_name_for_relay(relay_url: &str) -> String { format!("buzz-community-{}", &digest[..32]) } -fn buzz_mesh_name(state: &AppState) -> String { +pub(super) fn buzz_mesh_name(state: &AppState) -> String { buzz_mesh_name_for_relay(&relay::relay_ws_url_with_override(state)) } @@ -150,8 +175,13 @@ fn advance_mesh_status_cursor( Ok(cursor) } -async fn query_mesh_discovery_events(state: &AppState) -> Result, String> { - let mut events = relay::query_relay(state, &[mesh_llm::relay_membership_filter()]).await?; +async fn query_mesh_discovery_events_at( + state: &AppState, + relay_url: &str, +) -> Result, String> { + let api_base_url = relay::relay_http_base_url(relay_url); + let mut events = + relay::query_relay_at(state, &api_base_url, &[mesh_llm::relay_membership_filter()]).await?; let member_pubkeys = mesh_llm::current_member_pubkeys(&events); if member_pubkeys.is_empty() { // Distinguish "relay returned a membership snapshot listing zero @@ -172,7 +202,7 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result = None; loop { - let page = relay::query_relay(state, &[status_filter.clone()]).await?; + let page = relay::query_relay_at(state, &api_base_url, &[status_filter.clone()]).await?; let done = page.len() < mesh_llm::MESH_STATUS_PAGE_SIZE; if !done { let cursor = advance_mesh_status_cursor(&mut status_filter, &page)?; @@ -188,6 +218,10 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result Result, String> { + query_mesh_discovery_events_at(state, &relay::relay_ws_url_with_override(state)).await +} + /// Resolve the admission roster by intersecting member-signed mesh status /// reporters with the current NIP-43 direct-member list. /// @@ -201,6 +235,14 @@ pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Result Result, String> { + let events = query_mesh_discovery_events_at(state, relay_url).await?; + Ok(mesh_llm::owner_ids_from_events(&events)) +} + /// Resolve the roster for an initial node *start*, failing closed to self-only /// (an empty roster) when the relay query fails. This is safe only at start: /// there is no established allowlist to preserve yet. The periodic @@ -244,10 +286,11 @@ fn buzz_mesh_join_targets( /// Resolve the validated member endpoint this runtime should join to enter the /// existing Buzz community mesh. `Ok(None)` means this machine is the first /// live serving member (or is itself the shared bootstrap contact). -pub(crate) async fn resolve_buzz_mesh_join_targets( +pub(crate) async fn resolve_buzz_mesh_join_targets_at( state: &AppState, + relay_url: &str, ) -> Result, String> { - let events = query_mesh_discovery_events(state).await?; + let events = query_mesh_discovery_events_at(state, relay_url).await?; let self_owner_id = mesh_llm::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))? .owner_id; @@ -261,8 +304,11 @@ pub(crate) async fn resolve_buzz_mesh_join_targets( /// snapshot. A node start used to repeat the full membership + status query /// for each value, making Share Compute startup both slower and more exposed /// to inconsistent snapshots. -async fn resolve_buzz_mesh_startup(state: &AppState) -> (Vec, Option) { - match query_mesh_discovery_events(state).await { +async fn resolve_buzz_mesh_startup_at( + state: &AppState, + relay_url: &str, +) -> (Vec, Option) { + match query_mesh_discovery_events_at(state, relay_url).await { Ok(events) => { let trusted_owner_ids = mesh_llm::owner_ids_from_events(&events); let join_token = mesh_llm::ensure_owner_identity() @@ -291,32 +337,60 @@ async fn resolve_buzz_mesh_startup(state: &AppState) -> (Vec, Option CmdResult<()> { - let Some(config) = load_mesh_sharing_config(app)? else { + let Some(mut config) = load_mesh_sharing_config(app)? else { return Ok(()); }; - if !config.enabled || config.model_id.trim().is_empty() { + if (!config.enabled && !config.start_on_next_launch) || config.model_id.trim().is_empty() { return Ok(()); } + config.model_id = mesh_llm::canonical_curated_model_id(&config.model_id).to_string(); if state.mesh_llm_runtime.lock().await.is_some() { return Ok(()); } - let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(state).await; + let relay_url = config + .relay_url + .clone() + .unwrap_or_else(|| relay::relay_ws_url_with_override(state)); + let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup_at(state, &relay_url).await; let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { return Ok(()); } + if config.start_on_next_launch { + // Consume a role-switch request before doing any potentially long model + // work. If Buzz exits during that work, the next launch stays stopped. + config = pending_new_start_checkpoint(&config); + save_mesh_sharing_config(app, &config)?; + } + // This is restoration of a previously inference-ready serving node. Keep + // the enabled checkpoint armed while restoring so a transient startup + // failure does not silently turn Share Compute off. New starts remain + // disarmed in `mesh_start_node` until their first inference probe passes. let request = mesh_llm::StartMeshNodeRequest { mode: mesh_llm::MeshNodeMode::Serve, - model_id: Some(config.model_id), + model_id: Some(config.model_id.clone()), max_vram_gb: config.max_vram_gb, join_token, - mesh_name: Some(buzz_mesh_name(state)), + mesh_name: Some(buzz_mesh_name_for_relay(&relay_url)), + relay_url: Some(relay_url), trusted_owner_ids: Some(trusted_owner_ids), }; let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + let cleanup = started.stop().await; + if let Err(cleanup_error) = cleanup { + eprintln!( + "buzz-mesh: restored node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" + ); + } + return Err(format!("failed to restore Share Compute: {error}")); + } *runtime = Some(started); + config.enabled = true; + config.start_on_next_launch = false; + save_mesh_sharing_config(app, &config)?; drop(runtime); mesh_llm::publish_current_status_once(app, "restore").await; Ok(()) @@ -328,6 +402,11 @@ pub async fn mesh_start_node( state: State<'_, AppState>, mut request: mesh_llm::StartMeshNodeRequest, ) -> CmdResult { + let relay_url = relay::relay_ws_url_with_override(&state); + request.relay_url = Some(relay_url.clone()); + if let Some(model_id) = request.model_id.as_mut() { + *model_id = mesh_llm::canonical_curated_model_id(model_id).to_string(); + } let sharing_config = if request.mode == mesh_llm::MeshNodeMode::Serve { Some(sharing_config_from_request(&request)?) } else { @@ -362,13 +441,14 @@ pub async fn mesh_start_node( // Frontend requests never carry a roster. Resolve it and the bootstrap // endpoint from one snapshot so UI startup does not repeat relay probes. if request.trusted_owner_ids.is_none() || request.join_token.is_none() { - let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(&state).await; + let (trusted_owner_ids, join_token) = + resolve_buzz_mesh_startup_at(&state, &relay_url).await; request.trusted_owner_ids.get_or_insert(trusted_owner_ids); if request.join_token.is_none() { request.join_token = join_token; } } - request.mesh_name = Some(buzz_mesh_name(&state)); + request.mesh_name = Some(buzz_mesh_name_for_relay(&relay_url)); let mut runtime = state.mesh_llm_runtime.lock().await; let plan = match runtime.as_ref() { @@ -386,6 +466,13 @@ pub async fn mesh_start_node( return Err("mesh node is already running".to_string()); } + if let Some(config) = sharing_config.as_ref() { + // Do not arm launch restoration until the exact inference path used by + // agents succeeds. Mesh may bind its ports after primary weights load + // while package layers are still downloading. + save_mesh_sharing_config(&app, &pending_new_start_checkpoint(config))?; + } + let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("{error:#}"))?; @@ -409,6 +496,21 @@ pub async fn mesh_start_node( )); } }; + if let Some(config) = sharing_config.as_ref() { + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + let cleanup = started.stop().await; + if let Err(cleanup_error) = &cleanup { + eprintln!( + "buzz-mesh: started node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" + ); + } + drop(runtime); + app.request_restart(); + return Err(format!( + "mesh node started but inference never became ready: {error}; Buzz is restarting to guarantee cleanup" + )); + } + } *runtime = Some(started); drop(runtime); if let Some(config) = sharing_config.as_ref() { @@ -612,6 +714,7 @@ pub(crate) async fn ensure_client_node_for_model( max_vram_gb: None, join_token: Some(join_token.clone()), mesh_name: Some(buzz_mesh_name(state)), + relay_url: Some(relay::relay_ws_url_with_override(state)), trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await), }; let mut runtime = state.mesh_llm_runtime.lock().await; @@ -753,6 +856,18 @@ pub(crate) async fn ensure_relay_mesh_for_record( } } } + + // A persisted Share Compute configuration is authoritative about this + // machine's role. If no runtime is currently tracked (for example after a + // clean process restart), restore the serving node instead of treating an + // agent request as permission to replace it with a client node. + if load_mesh_sharing_config(app)? + .is_some_and(|config| config.enabled && !config.model_id.trim().is_empty()) + { + restore_mesh_sharing(app, &state).await?; + return wait_for_mesh_inference(model_id).await; + } + let target = match resolve_mesh_bootstrap_target(&state, model_id).await { Ok(Some(target)) => target, Ok(None) => { @@ -768,15 +883,9 @@ pub(crate) async fn ensure_relay_mesh_for_record( } }; - // Serve→Client re-arm transition (micspiral review #3, intentional-by-design): - // if the dead ingress belonged to a *serve* node with running consumer - // agents, this re-arms it as a Client (`MeshNodeMode::Client`). That is the - // correct/safe recovery here — config-backed serve restoration is - // `restore_mesh_sharing`'s job (`MeshNodeMode::Serve`), and - // `ensure_client_node_for_model` reuses any live runtime of *either* mode - // (the router resolves per-request), so it only cold-starts a Client when - // there is genuinely no runtime. Falling back to Client if a serve node - // crashed under local pressure is a desirable fail-safe, not a regression. + // No serving configuration exists, so this is a genuine consumer-only + // start. A configured serving machine is restored above and never reaches + // this client fallback. ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?; wait_for_mesh_inference(model_id).await } @@ -792,14 +901,17 @@ pub async fn mesh_stop_node( // role under the lock and, when it's a consume session, leave it running // and return its live status unchanged. The frontend also guards this, but // status can be stale between polls, so the backend is authoritative. - let taken = { + let (taken, bound_relay_url) = { let mut guard = state.mesh_llm_runtime.lock().await; if let Some(runtime) = guard.as_ref() { if !share_stop_should_teardown(runtime.mode()) { return runtime.status().await.map_err(|error| error.to_string()); } } - guard.take() + let bound_relay_url = guard + .as_ref() + .and_then(|runtime| runtime.start_request().relay_url.clone()); + (guard.take(), bound_relay_url) }; if let Some(runtime) = taken { runtime.stop().await.map_err(|error| error.to_string())?; @@ -808,11 +920,13 @@ pub async fn mesh_stop_node( &app, &MeshSharingConfig { enabled: false, + start_on_next_launch: false, model_id: String::new(), max_vram_gb: None, + relay_url: None, }, )?; - mesh_llm::publish_stopped_status_once(&app, "stop").await; + mesh_llm::publish_stopped_status_once_at(&app, bound_relay_url.as_deref(), "stop").await; Ok(mesh_llm::stopped_status()) } diff --git a/desktop/src-tauri/src/commands/mesh_llm_tests.rs b/desktop/src-tauri/src/commands/mesh_llm_tests.rs index ccc5287d6..26eb1f5fb 100644 --- a/desktop/src-tauri/src/commands/mesh_llm_tests.rs +++ b/desktop/src-tauri/src/commands/mesh_llm_tests.rs @@ -110,6 +110,74 @@ fn buzz_mesh_name_is_stable_and_does_not_expose_the_relay() { assert!(!first.contains("example")); } +#[test] +fn sharing_config_keeps_the_community_where_sharing_was_enabled() { + let request = mesh_llm::StartMeshNodeRequest { + mode: mesh_llm::MeshNodeMode::Serve, + model_id: Some("test-model".to_string()), + max_vram_gb: Some(24), + join_token: None, + mesh_name: Some("buzz-community-test".to_string()), + relay_url: Some("wss://community.example".to_string()), + trusted_owner_ids: Some(Vec::new()), + }; + + let config = sharing_config_from_request(&request).expect("valid sharing config"); + assert_eq!(config.relay_url.as_deref(), Some("wss://community.example")); +} + +#[test] +fn legacy_sharing_config_without_community_binding_still_loads() { + let config: MeshSharingConfig = serde_json::from_value(serde_json::json!({ + "enabled": true, + "modelId": "test-model", + "maxVramGb": null + })) + .expect("legacy sharing config"); + + assert_eq!(config.relay_url, None); + assert!(!config.start_on_next_launch); +} + +#[test] +fn new_start_checkpoint_prevents_incomplete_download_restore() { + let config = MeshSharingConfig { + enabled: true, + start_on_next_launch: false, + model_id: "test-model".to_string(), + max_vram_gb: Some(24), + relay_url: Some("wss://community.example".to_string()), + }; + + let checkpoint = pending_new_start_checkpoint(&config); + assert!(!checkpoint.enabled); + assert!(!checkpoint.start_on_next_launch); + assert_eq!(checkpoint.model_id, config.model_id); + assert_eq!(checkpoint.max_vram_gb, config.max_vram_gb); + assert_eq!(checkpoint.relay_url, config.relay_url); +} + +#[test] +fn role_switch_checkpoint_starts_exactly_once_after_restart() { + let config = MeshSharingConfig { + enabled: true, + start_on_next_launch: false, + model_id: "test-model".to_string(), + max_vram_gb: Some(24), + relay_url: Some("wss://community.example".to_string()), + }; + + let restart = one_shot_restart_checkpoint(&config); + assert!(!restart.enabled); + assert!(restart.start_on_next_launch); + + let consumed = pending_new_start_checkpoint(&restart); + assert!(!consumed.enabled); + assert!(!consumed.start_on_next_launch); + assert_eq!(consumed.model_id, config.model_id); + assert_eq!(consumed.relay_url, config.relay_url); +} + #[test] fn readiness_failure_is_catalog_sync_when_model_never_visible() { assert_eq!( @@ -345,6 +413,7 @@ fn ensure_serve_runtime_serves_other_model() { max_vram_gb: None, join_token: None, mesh_name: None, + relay_url: None, trusted_owner_ids: None, }) .await diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d23efe773..eccf8ee60 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -685,7 +685,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent /// POST a pre-built signed engram event to the relay, authenticating as the /// new agent. -async fn submit_engram_event( +pub(crate) async fn submit_engram_event( state: &AppState, agent_keys: &nostr::Keys, event_json: &[u8], @@ -695,6 +695,8 @@ async fn submit_engram_event( use crate::relay::build_nip98_auth_header_for_keys; use reqwest::Method; + crate::egress_guard::assert_no_key_backup_bytes(event_json, "persona snapshot engram submit")?; + // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the // wait produces a stale `created_at` that the relay will reject. @@ -739,6 +741,35 @@ async fn submit_engram_event( Ok(()) } +// ── NIP-49 egress guard: boundary 7 (persona snapshot engram submit) ───────── + +#[cfg(test)] +mod egress_guard_tests { + use super::submit_engram_event; + + const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + + /// An engram body carrying an ncryptsec must be rejected by the guard + /// before any network I/O (the target port is a discard address; a guard + /// error — not a connection error — proves the abort ordering). + #[tokio::test] + async fn blocks_ncryptsec_before_network() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let body = format!("{{\"content\":\"{NCRYPTSEC}\"}}"); + let err = submit_engram_event( + &state, + &keys, + body.as_bytes(), + "http://127.0.0.1:9/events", + None, + ) + .await + .unwrap_err(); + assert!(err.contains("key-backup material"), "{err}"); + } +} + #[cfg(test)] mod import_avatar_tests { use super::materialize_import_avatar; diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 91a0126f5..97cd11933 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -895,7 +895,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent /// POST a pre-built signed engram event to the relay, authenticating as the /// new agent. Mirrors the same helper in `snapshot::import`. -async fn submit_engram_event( +pub(crate) async fn submit_engram_event( state: &AppState, agent_keys: &nostr::Keys, event_json: &[u8], @@ -905,6 +905,8 @@ async fn submit_engram_event( use crate::relay::build_nip98_auth_header_for_keys; use reqwest::Method; + crate::egress_guard::assert_no_key_backup_bytes(event_json, "team snapshot engram submit")?; + // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the // wait produces a stale `created_at` that the relay will reject. diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 061641130..c9a6d8812 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -733,3 +733,31 @@ fn full_rollback_at_teams_boundary_absent_agents_store() { assert!(!teams_path.exists()); assert_eq!(errors.len(), 1, "only the teams-write error"); } + +// ── NIP-49 egress guard: boundary 6 (team snapshot engram submit) ──────────── + +mod egress_guard_boundary { + use super::super::submit_engram_event; + + const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + + /// An engram body carrying an ncryptsec must be rejected by the guard + /// before any network I/O (the target port is a discard address; a guard + /// error — not a connection error — proves the abort ordering). + #[tokio::test] + async fn blocks_ncryptsec_before_network() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let body = format!("{{\"content\":\"{NCRYPTSEC}\"}}"); + let err = submit_engram_event( + &state, + &keys, + body.as_bytes(), + "http://127.0.0.1:9/events", + None, + ) + .await + .unwrap_err(); + assert!(err.contains("key-backup material"), "{err}"); + } +} diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs new file mode 100644 index 000000000..db58ddafa --- /dev/null +++ b/desktop/src-tauri/src/egress_guard.rs @@ -0,0 +1,58 @@ +//! Relay egress guard for NIP-49 key-backup material. +//! +//! The local `ncryptsec` backup (see [`crate::key_backup`]) must NEVER be +//! transmitted to a relay. This module enforces that contract at runtime, +//! fail-closed, at every relay-bound egress boundary: +//! +//! | # | Boundary | Site | +//! |---|----------|------| +//! | 1 | `submit_signed_event_at_with_keys` (funnel for `submit_event*`) | `relay/submit.rs` | +//! | 2 | `sync_managed_agent_profile` | `relay.rs` | +//! | 3 | pre-signed path into the boundary-1 funnel | `relay/submit.rs` | +//! | 4 | `submit_signed_event_with_keys` | `relay.rs` | +//! | 5 | huddle STT publisher | `huddle/pipeline.rs` | +//! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` | +//! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` | +//! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` | +//! +//! The inventory-completeness test in `egress_guard_tests.rs` asserts that +//! every `/events` URL-construction site in the tree calls this guard, so a +//! new submission path fails the build until it is wired. +//! +//! Scope: `ncryptsec1` only. The raw `nsec` intentionally transits the +//! NIP-44-encrypted pairing session (NIP-AB payload_type "nsec"); guarding it +//! here would break pairing. Raw-key DLP is separate policy work. + +/// Bech32 HRP of NIP-49 encrypted secret keys. +const NCRYPTSEC_PREFIX: &str = "ncryptsec1"; +/// Bech32 also permits an ALL-UPPERCASE encoding of the same payload +/// (BIP-173); an uppercased valid backup decodes identically, so the guard +/// must reject it too. Mixed case is invalid bech32 and cannot decode — a +/// substring matching either all-lower or all-upper prefix covers every +/// decodable form. +const NCRYPTSEC_PREFIX_UPPER: &str = "NCRYPTSEC1"; + +/// Reject `text` if it contains NIP-49 key-backup material. +/// +/// Returns `Err` when an `ncryptsec1…` (or uppercase `NCRYPTSEC1…`) +/// substring is present. Callers MUST abort the network operation on `Err` — +/// this is a fail-closed guard, not a warning. +pub fn assert_no_key_backup(text: &str, context: &'static str) -> Result<(), String> { + if text.contains(NCRYPTSEC_PREFIX) || text.contains(NCRYPTSEC_PREFIX_UPPER) { + return Err(format!( + "blocked {context}: payload contains NIP-49 key-backup material \ + (ncryptsec); the local key backup must never be transmitted to a relay" + )); + } + Ok(()) +} + +/// Byte-slice variant for callers that hold serialized bodies. +pub fn assert_no_key_backup_bytes(body: &[u8], context: &'static str) -> Result<(), String> { + // ncryptsec is ASCII bech32; a UTF-8-lossy view preserves any occurrence. + assert_no_key_backup(&String::from_utf8_lossy(body), context) +} + +#[cfg(test)] +#[path = "egress_guard_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs new file mode 100644 index 000000000..f487c8ce1 --- /dev/null +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -0,0 +1,446 @@ +use super::*; + +/// NIP-49 spec vector — a real ncryptsec blob for injection payloads. +const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +fn assert_guard_error(err: &str) { + assert!( + err.contains("key-backup material"), + "expected the egress-guard error, got: {err}" + ); +} + +// ── Guard unit behavior ─────────────────────────────────────────────────────── + +#[test] +fn rejects_ncryptsec_anywhere_in_text() { + assert_guard_error(&assert_no_key_backup(NCRYPTSEC, "test").unwrap_err()); + assert_guard_error( + &assert_no_key_backup( + &format!("{{\"content\":\"my backup: {NCRYPTSEC}\"}}"), + "test", + ) + .unwrap_err(), + ); +} + +/// Bech32 permits an all-uppercase encoding of the same payload — an +/// uppercased valid backup must not bypass the guard (text and bytes). +/// Mixed case is invalid bech32 (cannot decode) and is deliberately not +/// blocked. +#[test] +fn rejects_uppercase_ncryptsec() { + let upper = NCRYPTSEC.to_ascii_uppercase(); + assert_guard_error(&assert_no_key_backup(&upper, "test").unwrap_err()); + assert_guard_error(&assert_no_key_backup_bytes(upper.as_bytes(), "test").unwrap_err()); + // Mixed case cannot decode; not blocked. + assert!(assert_no_key_backup("nCrYpTsEc1qgg9947r", "test").is_ok()); +} + +#[test] +fn passes_clean_payloads_including_raw_nsec() { + assert!(assert_no_key_backup("hello world", "test").is_ok()); + assert!(assert_no_key_backup("", "test").is_ok()); + // Scope is ncryptsec1 ONLY: raw nsec intentionally transits the encrypted + // pairing session and must NOT be blocked (plan D4 / pairing.rs). + let nsec = nostr::ToBech32::to_bech32(nostr::Keys::generate().secret_key()).unwrap(); + assert!(assert_no_key_backup(&nsec, "test").is_ok()); + // Near-miss prefixes are not blocked. + assert!(assert_no_key_backup("ncryptsec", "test").is_ok()); +} + +#[test] +fn byte_variant_matches_text_variant() { + assert_guard_error(&assert_no_key_backup_bytes(NCRYPTSEC.as_bytes(), "test").unwrap_err()); + assert!(assert_no_key_backup_bytes(b"clean body", "test").is_ok()); + // Invalid UTF-8 around an intact ncryptsec substring must still trip the + // guard (from_utf8_lossy preserves the ASCII run). + let mut body = vec![0xff, 0xfe]; + body.extend_from_slice(NCRYPTSEC.as_bytes()); + body.push(0xff); + assert_guard_error(&assert_no_key_backup_bytes(&body, "test").unwrap_err()); +} + +#[test] +fn error_names_the_boundary_context() { + let err = assert_no_key_backup(NCRYPTSEC, "huddle STT publish").unwrap_err(); + assert!(err.contains("huddle STT publish"), "{err}"); +} + +// ── Runtime injection per boundary ──────────────────────────────────────────── +// +// Each test drives the real production function with an ncryptsec-bearing +// payload and asserts the guard aborts the operation before any network I/O +// (no listener exists at the target address; a distinctive guard error — not +// a connection error — proves the abort happened first). +// +// Boundaries 6 and 7 (`submit_engram_event` twins) are module-private inside +// `commands`; their injection tests live next to them: +// - commands/team_snapshot/tests.rs::egress_guard_boundary +// - commands/personas/snapshot/import.rs::egress_guard_tests + +/// Boundary 1: `relay/submit.rs` `submit_event_at_with_keys` (the funnel for +/// all `submit_event*` variants). +#[tokio::test] +async fn boundary_submit_event_at_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let builder = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC); + let err = crate::relay::submit_event_at_with_keys( + builder, + &state, + "http://127.0.0.1:9", // discard port — must never be reached + &keys, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 2: `relay.rs` `sync_managed_agent_profile` (agent kind:0 profile). +#[tokio::test] +async fn boundary_sync_managed_agent_profile_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let err = crate::relay::sync_managed_agent_profile( + &state, + "ws://127.0.0.1:9", + &keys, + &format!("agent {NCRYPTSEC}"), + None, + None, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 3: `relay/submit.rs` `submit_signed_event_at_with_keys` — the +/// pre-signed entry into the boundary-1 funnel (main's submit refactor +/// replaced `relay.rs` `submit_signed_event` with this scoped form). +#[tokio::test] +async fn boundary_submit_signed_event_at_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC) + .sign_with_keys(&keys) + .unwrap(); + let err = crate::relay::submit_signed_event_at_with_keys( + &event, + &state, + "http://127.0.0.1:9", // discard port — must never be reached + &keys, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 4: `relay.rs` `submit_signed_event_with_keys`. +#[tokio::test] +async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some("ws://127.0.0.1:9".to_string()); + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC) + .sign_with_keys(&keys) + .unwrap(); + let err = crate::relay::submit_signed_event_with_keys(&event, &state, &keys, None) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 5: huddle STT publisher (`huddle/pipeline.rs`). +#[test] +fn boundary_huddle_stt_blocks_ncryptsec() { + let keys = nostr::Keys::generate(); + let channel = uuid::Uuid::new_v4(); + let builder = + crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap(); + let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err(); + assert_guard_error(&err); + + // Clean transcripts pass through the same seam. + let builder = + crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap(); + assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok()); +} + +/// Boundary 8: native websocket send loop — the single choke point for all +/// webview-originated relay websocket frames. +#[tokio::test] +async fn boundary_native_websocket_blocks_ncryptsec() { + let manager = crate::native_websocket::WebSocketManager::default(); + // Text frame: guard fires before the connection lookup, so no connection + // is needed — and the error must be the guard's, not "not found". + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Text(format!( + "[\"EVENT\",{{\"content\":\"{NCRYPTSEC}\"}}]" + )), + ) + .await + .unwrap_err(); + assert_guard_error(&err); + + // Binary frame variant. + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Binary(NCRYPTSEC.as_bytes().to_vec()), + ) + .await + .unwrap_err(); + assert_guard_error(&err); + + // Clean frames fall through to normal handling ("connection not found" + // here — the guard did not reject them). + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Text("[\"REQ\",\"sub\",{}]".to_string()), + ) + .await + .unwrap_err(); + assert!(err.contains("not found"), "{err}"); +} + +// ── Structural tripwires ────────────────────────────────────────────────────── + +fn src_rust_files() -> Vec { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { + out.push(path); + } + } + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut out = Vec::new(); + walk(&root, &mut out); + out +} + +/// Site-granular `/events` inventory: `(file suffix, expected non-comment +/// `/events` occurrences, expected guard call sites — full-path calls into +/// the egress-guard module)`. +/// +/// Every entry pairs the URL-construction count with the guard-call count for +/// that file, so BOTH of these fail the scan (not just a brand-new file): +/// - adding an unguarded ninth `/events` site inside an already-listed file +/// (count goes up without a matching table update), and +/// - removing/refactoring away a guard call while its egress site remains. +/// +/// Updating a row here is the deliberate act that must accompany wiring the +/// guard + adding an injection test for the new site. +const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ + // Production egress boundaries (see egress_guard.rs table): + ("src/relay.rs", 2, 2), // boundaries 2, 4 + ("src/relay/submit.rs", 1, 1), // boundaries 1 + 3 (shared funnel) + ("src/huddle/pipeline.rs", 1, 1), // boundary 5 + ("src/commands/team_snapshot.rs", 1, 1), // boundary 6 + ("src/commands/personas/snapshot/import.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL + ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) + // Test-only fixtures — no production egress, no guard: + ("src/relay_admission.rs", 1, 0), + ("src/archive/mod_tests.rs", 1, 0), + ("src/managed_agents/persona_events/tests.rs", 1, 0), + ("src/commands/team_snapshot/tests.rs", 1, 0), + // Mock-relay route in its in-file tests; production publish goes through + // the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`). + ("src/commands/personas/sharing.rs", 1, 0), +]; + +// Needles are assembled at runtime so this scan file itself contains no +// contiguous match and needs no self-referential inventory row. +fn events_needle() -> String { + ["/ev", "ents"].concat() +} +fn guard_needle() -> String { + ["egress_guard::", "assert_no_key_backup"].concat() +} + +/// Pure scan core over `(relative path, content)` pairs. Returns violations; +/// empty means every file matches its inventory row exactly (files absent +/// from the table are expected to have zero `/events` sites and zero guard +/// calls). +fn events_inventory_violations(files: &[(String, String)]) -> Vec { + let events = events_needle(); + let guard = guard_needle(); + let mut violations = Vec::new(); + + for (rel, content) in files { + let expected = EVENTS_INVENTORY + .iter() + .find(|(suffix, _, _)| rel.ends_with(suffix)) + .map(|&(_, e, g)| (e, g)) + .unwrap_or((0, 0)); + + let mut event_sites = Vec::new(); + for (i, line) in content.lines().enumerate() { + if line.trim_start().starts_with("//") { + continue; // doc/comment mentions + } + if line.contains(&events) { + event_sites.push(format!(" {rel}:{}: {}", i + 1, line.trim())); + } + } + let guard_count = content.matches(&guard).count(); + + if (event_sites.len(), guard_count) != expected { + violations.push(format!( + "{rel}: found {} events-URL site(s) + {} guard call(s), inventory \ + expects {} + {}. Sites found:\n{}", + event_sites.len(), + guard_count, + expected.0, + expected.1, + if event_sites.is_empty() { + " (none)".to_string() + } else { + event_sites.join("\n") + }, + )); + } + } + violations +} + +fn read_src_files() -> Vec<(String, String)> { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + src_rust_files() + .into_iter() + .map(|path| { + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let content = std::fs::read_to_string(&path).unwrap(); + (rel, content) + }) + .collect() +} + +/// Inventory completeness: every `/events` URL-construction site in +/// `desktop/src-tauri/src` must match the site-granular inventory above. A +/// future ninth submission path — in a NEW file or an ALREADY-LISTED one — +/// fails this test until its guard is wired, its injection test exists, and +/// its inventory row is updated. +#[test] +fn events_url_inventory_is_fully_guarded() { + let violations = events_inventory_violations(&read_src_files()); + assert!( + violations.is_empty(), + "events-URL egress inventory drift — wire crate::egress_guard, add an \ + injection test, then update EVENTS_INVENTORY:\n{}", + violations.join("\n") + ); +} + +/// Mutation-style proof of the tripwire's guarantee: an unguarded ninth +/// `/events` site added to an already-inventoried file (relay.rs) is caught. +#[test] +fn inventory_scan_catches_new_site_in_allowlisted_file() { + let mut files = read_src_files(); + let relay = files + .iter_mut() + .find(|(rel, _)| rel.ends_with("src/relay.rs")) + .expect("relay.rs must be in the scan set"); + relay.1.push_str(&format!( + "\nfn sneaky_ninth_site(base: &str) -> String {{ format!(\"{{base}}{}\") }}\n", + events_needle() + )); + let violations = events_inventory_violations(&files); + assert!( + violations.iter().any(|v| v.contains("src/relay.rs")), + "an unguarded ninth events-URL site in relay.rs must trip the scan: {violations:?}" + ); +} + +/// The pairing also fires in reverse: a guard call deleted while its egress +/// site remains is caught. +#[test] +fn inventory_scan_catches_removed_guard_call() { + let mut files = read_src_files(); + let relay = files + .iter_mut() + .find(|(rel, _)| rel.ends_with("src/relay.rs")) + .expect("relay.rs must be in the scan set"); + relay.1 = relay.1.replacen(&guard_needle(), "removed_guard", 1); + let violations = events_inventory_violations(&files); + assert!( + violations.iter().any(|v| v.contains("src/relay.rs")), + "a removed guard call in relay.rs must trip the scan: {violations:?}" + ); +} + +/// A brand-new file with an `/events` site (no inventory row) is caught. +#[test] +fn inventory_scan_catches_new_unlisted_file() { + let mut files = read_src_files(); + files.push(( + "src/brand_new_egress.rs".to_string(), + format!("let url = format!(\"{{}}{}\", base);", events_needle()), + )); + let violations = events_inventory_violations(&files); + assert!( + violations + .iter() + .any(|v| v.contains("src/brand_new_egress.rs")), + "{violations:?}" + ); +} + +/// Source allowlist: NIP-49 material handling is confined to the identity / +/// backup / import / guard files. Anything else touching ncryptsec or the +/// nip49 codec is structural drift. +#[test] +fn ncryptsec_handling_is_confined_to_allowlisted_files() { + let allowlist: &[&str] = &[ + "src/key_backup.rs", + "src/key_backup_tests.rs", + "src/egress_guard.rs", + "src/egress_guard_tests.rs", + "src/commands/identity.rs", + "src/commands/identity_key_backup_tests.rs", + "src/lib.rs", // module registration + invoke handler + // boundary wiring (guard call sites name the module, not the codec): + "src/relay.rs", + "src/relay/submit.rs", + "src/huddle/pipeline.rs", + "src/commands/team_snapshot.rs", + "src/commands/team_snapshot/tests.rs", + "src/commands/personas/snapshot/import.rs", + "src/native_websocket.rs", + ]; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut violations = Vec::new(); + for path in src_rust_files() { + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if allowlist.iter().any(|a| rel.ends_with(a)) { + continue; + } + let content = std::fs::read_to_string(&path).unwrap(); + for needle in ["ncryptsec", "EncryptedSecretKey", "nip49"] { + if content.contains(needle) { + violations.push(format!("{rel}: contains {needle:?}")); + } + } + } + assert!( + violations.is_empty(), + "NIP-49 material outside allowlisted files:\n{}", + violations.join("\n") + ); +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index ceccedd8b..6a4cf2620 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -251,6 +251,23 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result, String> { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("sign event: {e}"))?; + let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "huddle STT publish")?; + Ok(body_bytes) +} + /// Spawn a tokio task that reads text_rx and posts kind:9 events. /// /// Fix 1: `agent_pubkeys_arc` is an `Arc>>` cloned from @@ -310,14 +327,13 @@ pub(crate) fn spawn_transcription_task( // the kind event and build NIP-98 auth after the wait so both // timestamps are fresh — single clean order: wait → sign → auth → send. crate::relay_admission::wait_for_rate_limit().await; - let event = match builder.sign_with_keys(&keys) { - Ok(e) => e, + let body_bytes = match sign_and_guard_stt_body(builder, &keys) { + Ok(b) => b, Err(e) => { - eprintln!("buzz-desktop: STT sign event: {e}"); + eprintln!("buzz-desktop: STT publish: {e}"); continue; } }; - let body_bytes = event.as_json().into_bytes(); let url = format!("{relay_base_url}/events"); let auth_header = match crate::relay::build_nip98_auth_header_for_keys( &keys, diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs new file mode 100644 index 000000000..6396911ae --- /dev/null +++ b/desktop/src-tauri/src/key_backup.rs @@ -0,0 +1,187 @@ +//! NIP-49 encrypted local key backup. +//! +//! Creates a password-encrypted `ncryptsec` backup of the user's identity key +//! for a user-selected local file. The blob is **local-only by contract**: it must +//! never be transmitted to a relay on any path. That contract is enforced at +//! runtime by [`crate::egress_guard`] (wired into every relay event-body +//! constructor and the native websocket send loop) and structurally by the +//! source-allowlist scan in this module's tests. +//! +//! Creation decrypt-verifies the fresh blob against the live identity before +//! returning it. Portable copies use atomic, owner-only file writes. + +use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; +use nostr::{FromBech32, Keys, ToBech32}; + +/// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB). +/// The blob self-describes its cost, so this can be raised later without +/// breaking existing backups. +pub const BACKUP_LOG_N: u8 = 18; + +/// Highest scrypt cost accepted when decrypting an untrusted backup. +/// +/// NIP-49 intentionally leaves `log_n` client-selected. Capping it at the tier +/// Buzz itself emits keeps generated and upstream-compatible lower-cost backups +/// readable without allowing a crafted payload to request unbounded memory +/// before password authentication. +pub const MAX_VERIFY_LOG_N: u8 = BACKUP_LOG_N; + +/// Filename of the app-managed canonical backup inside the app data dir. +pub const BACKUP_FILE_NAME: &str = "identity.ncryptsec"; + +/// Default number of words in a generated backup passphrase. Three words +/// from a 1296-word list ≈ 31 bits of entropy before the scrypt work factor. +pub const DEFAULT_PASSPHRASE_WORDS: usize = 3; + +/// Bounds for the generator's word-count control. At the lower bound a draw +/// can fall below [`MIN_PASSPHRASE_LEN`] (three 3-char words), so +/// [`generate_passphrase`] re-draws until the phrase meets the minimum. +pub const MIN_PASSPHRASE_WORDS: usize = 3; +pub const MAX_PASSPHRASE_WORDS: usize = 10; + +/// EFF short wordlist 2.0 (1296 words, one per line). +const WORDLIST: &str = include_str!("assets/eff_short_wordlist_2_0.txt"); + +/// Minimum length for a user-chosen passphrase. +pub const MIN_PASSPHRASE_LEN: usize = 12; + +/// Encrypt the identity secret key under `password` and verify the result. +/// +/// Returns the bech32 `ncryptsec1…` string. The fresh blob is decrypted and +/// its derived pubkey compared to the live identity **before** returning, so +/// a returned blob is always provably recoverable with the same password. +pub fn create_backup_blob(keys: &Keys, password: &str, log_n: u8) -> Result { + let secret_key = keys.secret_key(); + + let encrypted = EncryptedSecretKey::new(secret_key, password, log_n, KeySecurity::Unknown) + .map_err(|e| format!("encrypt key backup: {e}"))?; + + let ncryptsec = encrypted + .to_bech32() + .map_err(|e| format!("encode ncryptsec: {e}"))?; + + // Integrity check: decrypt the fresh blob and confirm it recovers the + // exact live identity. A corrupted or mis-encrypted blob must never be + // shown to the user as a "backup". This is the second, deliberate KDF + // invocation of the one-artifact-per-action contract. + verify_backup_blob(&ncryptsec, password, &keys.public_key())?; + + Ok(ncryptsec) +} + +/// Decrypt `ncryptsec` with `password` and assert it recovers a key whose +/// public key equals `expected_pubkey`. +pub fn verify_backup_blob( + ncryptsec: &str, + password: &str, + expected_pubkey: &nostr::PublicKey, +) -> Result<(), String> { + let encrypted = parse_ncryptsec(ncryptsec)?; + let recovered = encrypted + .decrypt(password) + .map_err(|e| format!("verify key backup (decrypt): {e}"))?; + let recovered_keys = Keys::new(recovered); + if recovered_keys.public_key() != *expected_pubkey { + return Err("verify key backup: decrypted key does not match identity".to_string()); + } + Ok(()) +} + +/// Parse a bech32 `ncryptsec1…` string, rejecting anything that is not a +/// structurally valid NIP-49 payload. +pub fn parse_ncryptsec(input: &str) -> Result { + EncryptedSecretKey::from_bech32(input.trim()).map_err(|e| format!("invalid ncryptsec: {e}")) +} + +/// Decrypt an `ncryptsec1…` string with `password` into identity keys. +pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { + let encrypted = parse_ncryptsec(input)?; + let log_n = encrypted.log_n(); + if log_n > MAX_VERIFY_LOG_N { + return Err(format!( + "unsupported backup KDF cost: log_n {log_n} exceeds maximum {MAX_VERIFY_LOG_N}" + )); + } + let secret_key = encrypted + .decrypt(password) + .map_err(|_| "wrong backup password or damaged key backup".to_string())?; + Ok(Keys::new(secret_key)) +} + +/// Atomically write `ncryptsec` to `path` with owner-only permissions, then +/// reread and byte-compare. Same crash-safety pattern as +/// `app_state::save_key_file`. +pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { + use atomic_write_file::AtomicWriteFile; + use std::io::Write; + + let mut file = AtomicWriteFile::open(path) + .map_err(|e| format!("open backup file for atomic write: {e}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("set backup file permissions: {e}"))?; + } + + file.write_all(ncryptsec.as_bytes()) + .map_err(|e| format!("write backup file: {e}"))?; + file.commit() + .map_err(|e| format!("commit backup file: {e}"))?; + + // Reread and byte-compare: only report success for bytes that are + // actually on disk. + let on_disk = std::fs::read_to_string(path).map_err(|e| format!("reread backup file: {e}"))?; + if on_disk != ncryptsec { + return Err("backup file verification failed: on-disk bytes differ".to_string()); + } + + Ok(()) +} + +/// Generate a passphrase of `word_count` EFF short-wordlist words joined by +/// `separator`, using OS entropy. +/// +/// `word_count` is clamped to `MIN_PASSPHRASE_WORDS..=MAX_PASSPHRASE_WORDS`. +/// Because a low-word-count draw can land under [`MIN_PASSPHRASE_LEN`] +/// (e.g. three 3-char words), whole phrases below the minimum are rejected +/// and re-drawn — the result always passes the same length gate applied to +/// user-chosen passphrases. Uses rejection sampling for a uniform +/// distribution over the 1296 words. +pub fn generate_passphrase(word_count: usize, separator: &str) -> Result { + let word_count = word_count.clamp(MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS); + let words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect(); + if words.len() != 1296 { + return Err(format!( + "wordlist corrupted: expected 1296 words, found {}", + words.len() + )); + } + + // At 3 words the under-length probability per draw is small, so a few + // attempts always suffice; the cap only guards against a logic bug + // becoming an infinite loop. + for _ in 0..128 { + let mut chosen: Vec<&str> = Vec::with_capacity(word_count); + while chosen.len() < word_count { + let mut buf = [0u8; 2]; + getrandom::getrandom(&mut buf).map_err(|e| format!("entropy source: {e}"))?; + let value = u16::from_le_bytes(buf); + // Rejection sampling: accept only values below the largest + // multiple of 1296 that fits in u16 (65536 - 65536 % 1296 = 64800). + if value < 64800 { + chosen.push(words[(value as usize) % 1296]); + } + } + let phrase = chosen.join(separator); + if phrase.chars().count() >= MIN_PASSPHRASE_LEN { + return Ok(phrase); + } + } + Err("could not generate a passphrase meeting the minimum length".to_string()) +} + +#[cfg(test)] +#[path = "key_backup_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs new file mode 100644 index 000000000..e5892ad99 --- /dev/null +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -0,0 +1,155 @@ +use super::*; + +/// NIP-49 spec vector (same as rust-nostr's upstream test): decrypts with +/// password "nostr" at our call sites. +const SPEC_NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; +const SPEC_SECRET_HEX: &str = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683"; + +/// Fast scrypt tier for tests. log_n 18 is exercised once in +/// `round_trip_at_production_cost`. +const FAST_LOG_N: u8 = 16; + +// ── Codec ───────────────────────────────────────────────────────────────────── + +#[test] +fn spec_vector_decrypts_at_our_call_site() { + let keys = decrypt_ncryptsec(SPEC_NCRYPTSEC, "nostr").unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); +} + +#[test] +fn round_trip_fast_tier() { + let keys = Keys::generate(); + let blob = create_backup_blob(&keys, "correct horse battery", FAST_LOG_N).unwrap(); + assert!(blob.starts_with("ncryptsec1")); + let recovered = decrypt_ncryptsec(&blob, "correct horse battery").unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn round_trip_at_production_cost() { + // One log_n 18 round trip: proves the production constant works end to + // end (slow — several seconds — but deliberate; see plan D5). + let keys = Keys::generate(); + let blob = create_backup_blob(&keys, "production cost tier check", BACKUP_LOG_N).unwrap(); + let recovered = decrypt_ncryptsec(&blob, "production cost tier check").unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn wrong_password_is_a_friendly_error() { + let keys = Keys::generate(); + let blob = create_backup_blob(&keys, "right password", FAST_LOG_N).unwrap(); + let err = decrypt_ncryptsec(&blob, "wrong password").unwrap_err(); + assert_eq!(err, "wrong backup password or damaged key backup"); +} + +#[test] +fn nfkc_cross_form_passphrase_round_trips() { + // "é" composed (U+00E9) vs decomposed (e + U+0301): NIP-49 mandates NFKC + // normalization, so a passphrase entered in either form must decrypt. + let keys = Keys::generate(); + let composed = "caf\u{00e9} passphrase"; + let decomposed = "cafe\u{0301} passphrase"; + assert_ne!(composed, decomposed); + let blob = create_backup_blob(&keys, composed, FAST_LOG_N).unwrap(); + let recovered = decrypt_ncryptsec(&blob, decomposed).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn parse_rejects_garbage_and_wrong_hrp() { + assert!(parse_ncryptsec("garbage").is_err()); + assert!(parse_ncryptsec("").is_err()); + // Valid bech32, wrong HRP (an nsec is not an encrypted backup). + let nsec = Keys::generate().secret_key().to_bech32().unwrap(); + assert!(parse_ncryptsec(&nsec).is_err()); + // Truncated blob. + assert!(parse_ncryptsec(&SPEC_NCRYPTSEC[..SPEC_NCRYPTSEC.len() - 10]).is_err()); +} + +#[test] +fn verify_backup_blob_catches_pubkey_mismatch() { + // Corrupted-blob simulation: the blob decrypts fine but recovers a key + // that is not the live identity — verification must fail. + let other = Keys::generate(); + let blob = create_backup_blob(&other, "some password", FAST_LOG_N).unwrap(); + let live = Keys::generate(); + let err = verify_backup_blob(&blob, "some password", &live.public_key()).unwrap_err(); + assert!(err.contains("does not match identity"), "{err}"); +} + +// ── File lifecycle ──────────────────────────────────────────────────────────── + +#[test] +fn write_backup_file_persists_0600_and_verifies() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(BACKUP_FILE_NAME); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!(on_disk, SPEC_NCRYPTSEC); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "backup file must be owner-only"); + } +} + +#[test] +fn write_backup_file_overwrites_atomically() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(BACKUP_FILE_NAME); + write_backup_file(&path, "ncryptsec1old").unwrap(); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); + // No leftover temp files from the atomic write. + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); +} + +#[test] +fn generated_passphrase_respects_word_count_and_separator() { + let words: std::collections::HashSet<&str> = + WORDLIST.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(words.len(), 1296, "EFF short wordlist 2.0 has 1296 words"); + + for (count, separator) in [(3, "-"), (4, "-"), (6, " "), (5, "."), (10, "")] { + let phrase = generate_passphrase(count, separator).unwrap(); + if separator.is_empty() { + // No separator to split on; length gate below still applies. + } else { + let parts: Vec<&str> = phrase.split(separator).collect(); + assert_eq!(parts.len(), count); + for w in &parts { + assert!(words.contains(w), "unknown word {w:?}"); + } + } + assert!(phrase.chars().count() >= MIN_PASSPHRASE_LEN); + } +} + +#[test] +fn generated_passphrase_clamps_word_count() { + // Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter. + let phrase = generate_passphrase(1, "-").unwrap(); + assert_eq!(phrase.split('-').count(), MIN_PASSPHRASE_WORDS); + // Above the ceiling: clamped down to MAX_PASSPHRASE_WORDS. + let phrase = generate_passphrase(50, "-").unwrap(); + assert_eq!(phrase.split('-').count(), MAX_PASSPHRASE_WORDS); +} + +#[test] +fn generated_passphrases_are_not_repeated() { + // 3 words × ~10.3 bits each — a collision across 8 draws would indicate a + // broken entropy source, not bad luck. + let mut seen = std::collections::HashSet::new(); + for _ in 0..8 { + assert!(seen.insert(generate_passphrase(DEFAULT_PASSPHRASE_WORDS, "-").unwrap())); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35ba41bad..7dcc5994a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,9 +4,12 @@ mod archive; mod builderlab; mod commands; mod deep_link; +mod egress_guard; mod event_sync; mod events; mod huddle; +mod key_backup; +mod linux_media; mod managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -199,6 +202,11 @@ pub fn run() { return; } + // Linux/WebKitGTK needs media-stream settings and a + // permission-request handler for getUserMedia; no-op + // on macOS/Windows. + linux_media::enable_media_capture(&webview); + // macOS applies the restored geometry asynchronously. Wait // for several identical outer bounds and for React to // commit the startup surface before revealing it. @@ -663,6 +671,10 @@ pub fn run() { title_bar_double_click, get_identity, get_nsec, + generate_backup_passphrase, + create_ncryptsec_backup, + verify_ncryptsec_backup, + save_ncryptsec_copy, import_identity, persist_current_identity, get_profile, diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs new file mode 100644 index 000000000..c768e1542 --- /dev/null +++ b/desktop/src-tauri/src/linux_media.rs @@ -0,0 +1,141 @@ +//! Linux-only: enable media capture (`getUserMedia`) in the WebKitGTK webview. +//! +//! On macOS (WKWebView) and Windows (WebView2) the media-permission prompt is +//! routed to the OS automatically, so microphone/camera capture "just works". +//! WebKitGTK is different on two counts, and both must be handled or capture +//! fails on Linux only: +//! +//! * `enable-media-stream` is **off by default**, so `navigator.mediaDevices` +//! never exposes a working `getUserMedia`; and +//! * the default `permission-request` handler **denies every request**, so even +//! with media-stream on, the call rejects with `NotAllowedError`. +//! +//! This module reaches the underlying `webkit2gtk::WebView` via +//! [`tauri::Webview::with_webview`], enables media-stream, and installs a +//! `permission-request` handler that is **deny-by-default**: a `UserMedia` +//! request is allowed only when it comes from a trusted app origin and asks for +//! an audio and/or video device. Tauri does not restrict navigation by default, +//! so without the origin check any document that ended up in this webview would +//! inherit silent mic/camera access for the process lifetime. +//! +//! Buzz's AppImage pins `GDK_BACKEND=x11` (see [`crate::webkit_rendering`]), +//! which is the backend WebKitGTK media capture is reliable on. + +/// The origin Tauri serves the packaged app from on Linux. +const PROD_ORIGIN: &str = "tauri://localhost"; + +/// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort` +/// 1420 in `vite.config.ts`). Only trusted in debug builds. +#[cfg(debug_assertions)] +const DEV_ORIGIN: &str = "http://localhost:1420"; + +/// Whether `uri` (the webview's current document URI) is a trusted app origin +/// allowed to use mic/camera. Matches the origin exactly or as a path prefix so +/// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip +/// through. Pure and platform-independent so it can be unit-tested everywhere. +fn is_trusted_media_origin(uri: &str) -> bool { + fn matches(uri: &str, origin: &str) -> bool { + uri == origin + || uri + .strip_prefix(origin) + .is_some_and(|rest| rest.starts_with('/')) + } + + if matches(uri, PROD_ORIGIN) { + return true; + } + #[cfg(debug_assertions)] + if matches(uri, DEV_ORIGIN) { + return true; + } + false +} + +/// Enable microphone/camera capture for `webview` if it is running on +/// WebKitGTK. A no-op on every non-Linux target, so callers can invoke it +/// unconditionally from shared startup code. +#[cfg(target_os = "linux")] +pub fn enable_media_capture(webview: &tauri::Webview) { + use webkit2gtk::{ + glib::prelude::Cast, PermissionRequestExt, SettingsExt, UserMediaPermissionRequest, + UserMediaPermissionRequestExt, WebViewExt, + }; + + // `with_webview` runs the closure on the UI thread, which GTK calls + // require. It errors only if the platform webview is unavailable. + let result = webview.with_webview(|platform_webview| { + // On Linux this is the underlying `webkit2gtk::WebView`. + let webview = platform_webview.inner(); + + if let Some(settings) = WebViewExt::settings(&webview) { + settings.set_enable_media_stream(true); + } + + // Deny-by-default: allow only mic/camera requests from a trusted app + // origin; deny everything else (still returning `true` so WebKit's + // auto-deny default does not also run). Non-`UserMedia` requests return + // `false` and keep their default handling. + webview.connect_permission_request(|wv, request| { + let Some(request) = request.downcast_ref::() else { + return false; + }; + + let uri = wv.uri().map(|u| u.to_string()).unwrap_or_default(); + let for_device = request.is_for_audio_device() || request.is_for_video_device(); + + if for_device && is_trusted_media_origin(&uri) { + request.allow(); + } else { + request.deny(); + } + true + }); + }); + + if let Err(error) = result { + eprintln!("buzz-desktop: could not enable WebKitGTK media capture: {error}"); + } +} + +/// No-op stub so shared startup code can call [`enable_media_capture`] on every +/// platform. macOS and Windows route media permissions through the OS. +#[cfg(not(target_os = "linux"))] +pub fn enable_media_capture(_webview: &tauri::Webview) {} + +#[cfg(test)] +mod tests { + use super::is_trusted_media_origin; + + #[test] + fn allows_production_app_origin() { + assert!(is_trusted_media_origin("tauri://localhost")); + assert!(is_trusted_media_origin( + "tauri://localhost/channels/general" + )); + } + + #[test] + fn denies_untrusted_origins() { + assert!(!is_trusted_media_origin("")); + assert!(!is_trusted_media_origin("https://evil.example.com")); + // Prefix look-alikes must not slip through. + assert!(!is_trusted_media_origin("tauri://localhost.evil.com")); + assert!(!is_trusted_media_origin("tauri://localhostfoo")); + } + + #[cfg(debug_assertions)] + #[test] + fn allows_dev_origin_in_debug_only() { + assert!(is_trusted_media_origin("http://localhost:1420")); + assert!(is_trusted_media_origin("http://localhost:1420/")); + // A different localhost port is still untrusted. + assert!(!is_trusted_media_origin("http://localhost:14200")); + assert!(!is_trusted_media_origin("http://localhost:3000")); + } + + #[cfg(not(debug_assertions))] + #[test] + fn denies_dev_origin_in_release() { + assert!(!is_trusted_media_origin("http://localhost:1420")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 7a6f5b094..327c106bc 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -42,15 +42,51 @@ pub fn apply_relay_mesh_env( RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(), "1".to_string(), ); - // Keep the requested response inside smaller local-model context windows, - // and spend that budget on an answer/tool call instead of hidden reasoning. - // Without both settings Qwen3 either fails the router's fit check at the - // agent default (32K) or can consume a tight cap before serializing a tool. - env.insert( - "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), - "4096".to_string(), - ); - env.insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "none".to_string()); + // Keep the requested response inside smaller local-model context windows. + // These are defaults, not policy: the effective agent/persona/global env + // may deliberately choose a smaller cap or a different effort. This function + // runs after those layers during readiness, so never clobber their values. + insert_default_if_unset(env, "BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096"); + // Deliberately no BUZZ_AGENT_THINKING_EFFORT default: mesh translates + // `reasoning_effort` into the chat template's `enable_thinking` flag, so any + // value we pick overrides each model's own template default — and the right + // value is model-specific. Measured with the real prompt and toolset: + // gemma-4-E4B delivers 0/8 at `none` but 6/6 with the field absent, while + // Qwen3-8B delivers 8/8 either way and burns ~4x the output tokens once + // thinking is on (121 -> ~470), risking the 4096 cap. Omitting the field + // lets every model use its own default; explicit agent/persona/global + // values still apply. +} + +#[cfg(feature = "mesh-llm")] +fn insert_default_if_unset( + env: &mut std::collections::BTreeMap, + key: &str, + value: &str, +) { + if env.get(key).is_none_or(|current| current.trim().is_empty()) { + env.insert(key.to_string(), value.to_string()); + } +} + +/// Build the final Mesh-specific process overrides from the already-resolved +/// harness environment. Only user-owned generation controls are seeded: the +/// derived provider/base URL/model values remain authoritative, and unrelated +/// credentials (notably `OPENAI_API_KEY`) must not be copied back after the +/// spawn path removes them. +#[cfg(feature = "mesh-llm")] +pub fn relay_mesh_process_env( + effective_env: &std::collections::BTreeMap, + model: &str, +) -> std::collections::BTreeMap { + let mut env = std::collections::BTreeMap::new(); + for key in ["BUZZ_AGENT_MAX_OUTPUT_TOKENS", "BUZZ_AGENT_THINKING_EFFORT"] { + if let Some(value) = effective_env.get(key) { + env.insert(key.to_string(), value.clone()); + } + } + apply_relay_mesh_env(&mut env, Some(RELAY_MESH_PROVIDER_ID), Some(model)); + env } #[cfg(all(test, feature = "mesh-llm"))] @@ -60,7 +96,7 @@ mod tests { use super::*; #[test] - fn native_provider_uses_context_safe_non_reasoning_budget() { + fn native_provider_uses_context_safe_tool_calling_budget() { let mut env = BTreeMap::new(); apply_relay_mesh_env( &mut env, @@ -72,14 +108,63 @@ mod tests { env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), Some("4096") ); - assert_eq!( - env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str), - Some("none") - ); + // Must stay unset: any value we pick overrides the model's own chat + // template default, and the right value is model-specific ("none" + // stops gemma tool-calling; enabling thinking makes Qwen3 burn ~4x the + // output budget). + assert_eq!(env.get("BUZZ_AGENT_THINKING_EFFORT"), None); assert_eq!( env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV) .map(String::as_str), Some("1") ); } + + #[test] + fn native_provider_preserves_explicit_generation_controls() { + let mut env = BTreeMap::from([ + ( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "2048".to_string(), + ), + ("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()), + ]); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), + Some("2048") + ); + assert_eq!( + env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str), + Some("high") + ); + } + + #[test] + fn process_env_seeds_controls_without_restoring_unrelated_credentials() { + let effective_env = BTreeMap::from([ + ( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "1024".to_string(), + ), + ("OPENAI_API_KEY".to_string(), "must-not-leak".to_string()), + ]); + + let env = relay_mesh_process_env(&effective_env, "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), + Some("1024") + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some("Gemma-4") + ); + assert!(!env.contains_key("OPENAI_API_KEY")); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67f..37927961e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -869,12 +869,7 @@ pub fn spawn_agent_child( // uses the same trim semantics as the preflight callers. #[cfg(feature = "mesh-llm")] if let Some(ref mesh_model_id) = mesh_model_id { - let mut mesh_env = std::collections::BTreeMap::new(); - super::apply_relay_mesh_env( - &mut mesh_env, - Some(super::RELAY_MESH_PROVIDER_ID), - Some(mesh_model_id.as_str()), - ); + let mesh_env = super::relay_mesh_process_env(&descriptor.env, mesh_model_id); command.env_remove("OPENAI_API_KEY"); for (key, value) in mesh_env { command.env(key, value); diff --git a/desktop/src-tauri/src/mesh_llm/catalog.rs b/desktop/src-tauri/src/mesh_llm/catalog.rs index 385971cb8..1a11fcfcd 100644 --- a/desktop/src-tauri/src/mesh_llm/catalog.rs +++ b/desktop/src-tauri/src/mesh_llm/catalog.rs @@ -19,12 +19,14 @@ use mesh_llm_system::vram::{format_rated_capacity, rated_capacity_gb}; /// The large pick is resolved through mesh-llm's remote catalog /// (huggingface.co/datasets/meshllm/catalog), so it does not need to exist in /// the compiled `MODEL_CATALOG`; the entry is synthesized below. -const CURATED_LARGE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M"; +const CURATED_LARGE: &str = "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"; +const CURATED_LARGE_ALIAS: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M"; const CURATED_LARGE_SIZE: &str = "17GB"; const CURATED_LARGE_FILE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf"; const CURATED_LARGE_DESCRIPTION: &str = "Gemma 4 26B MoE (4B active) — Buzz default for 64GB+ machines"; -const CURATED_SMALL: &str = "Gemma-4-E4B-it-Q4_K_M"; +const CURATED_SMALL: &str = "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"; +const CURATED_SMALL_ALIAS: &str = "Gemma-4-E4B-it-Q4_K_M"; /// Rated-capacity boundary between the two curated tiers, in GB (marketing /// capacity — a "64GB" Mac rates as 64 even though usable AI memory is less). const CURATED_LARGE_MIN_RATED_GB: u64 = 64; @@ -37,6 +39,16 @@ fn buzz_recommended_model(rated_gb: Option) -> &'static str { } } +/// Convert Buzz's pre-0.74 curated package aliases into the canonical model +/// ids advertised and accepted by Mesh's OpenAI ingress. +pub(crate) fn canonical_curated_model_id(model_id: &str) -> &str { + match model_id.trim() { + CURATED_SMALL_ALIAS => CURATED_SMALL, + CURATED_LARGE_ALIAS => CURATED_LARGE, + other => other, + } +} + /// How a model sits inside this machine's usable AI memory. /// Mirrors mesh-llm's private `fit_code_for_size_label` thresholds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -146,12 +158,13 @@ fn build_catalog( .filter(|m| !is_draft_only(&m.name)) .map(|m| { let size_gb = parse_size_gb(&m.size); + let name = canonical_curated_model_id(&m.name).to_string(); MeshCatalogEntry { fit: fit_code(size_gb, vram_gb), - installed: is_installed(&m.file, &m.name), + installed: is_installed(&m.file, &name) || is_installed(&m.file, &m.name), recommended: false, curated: false, - name: m.name.clone(), + name, size: m.size.clone(), size_gb, description: m.description.clone(), @@ -166,7 +179,8 @@ fn build_catalog( let size_gb = parse_size_gb(CURATED_LARGE_SIZE); entries.push(MeshCatalogEntry { fit: fit_code(size_gb, vram_gb), - installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE), + installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE) + || is_installed(CURATED_LARGE_FILE, CURATED_LARGE_ALIAS), recommended: false, curated: false, name: CURATED_LARGE.to_string(), @@ -256,6 +270,8 @@ mod tests { #[test] fn recommendation_follows_buzz_curated_tiers() { + assert_eq!(CURATED_SMALL, "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"); + assert_eq!(CURATED_LARGE, "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"); // 64GB+ rated machines get the large curated pick. let large = build_catalog(None, 64_000_000_000, 64.0, &[]); assert_eq!(large.recommended.as_deref(), Some(CURATED_LARGE)); @@ -269,6 +285,22 @@ mod tests { assert_eq!(tiny.recommended.as_deref(), Some(CURATED_SMALL)); } + #[test] + fn curated_package_aliases_migrate_to_openai_model_ids() { + assert_eq!( + canonical_curated_model_id(CURATED_SMALL_ALIAS), + CURATED_SMALL + ); + assert_eq!( + canonical_curated_model_id(CURATED_LARGE_ALIAS), + CURATED_LARGE + ); + assert_eq!( + canonical_curated_model_id("other/model:Q4"), + "other/model:Q4" + ); + } + #[test] fn curated_picks_lead_the_catalog() { let catalog = build_catalog(None, 96_000_000_000, 96.0, &[]); diff --git a/desktop/src-tauri/src/mesh_llm/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs index 1e279353b..066fa4637 100644 --- a/desktop/src-tauri/src/mesh_llm/coordinator.rs +++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs @@ -132,7 +132,7 @@ pub async fn start_coordinator(app: AppHandle) { /// MeshLLM establishes the encrypted peer transport itself. async fn reconcile_buzz_mesh_join(app: &AppHandle) -> Result<(), String> { let state = app.state::(); - let peer_ids = { + let (peer_ids, relay_url) = { let runtime = state.mesh_llm_runtime.lock().await; let Some(runtime) = runtime.as_ref() else { return Ok(()); @@ -141,10 +141,16 @@ async fn reconcile_buzz_mesh_join(app: &AppHandle) -> Result<(), String> { .status_report_payload() .await .map_err(|error| error.to_string())?; - visible_peer_ids(&payload) + let relay_url = runtime + .start_request() + .relay_url + .clone() + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(&state)); + (visible_peer_ids(&payload), relay_url) }; - let targets = crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state).await?; + let targets = + crate::commands::mesh_llm::resolve_buzz_mesh_join_targets_at(&state, &relay_url).await?; let Some(target) = targets .into_iter() .find(|target| !target_is_visible(target, &peer_ids)) @@ -201,8 +207,13 @@ fn target_is_visible(target: &crate::mesh_llm::MeshServeTarget, peer_ids: &[Stri enum RosterReconcileAction { /// Keep the running allowlist untouched (no-op, or a failure we ride out). Keep, - /// Restart the node with a freshly resolved roster. - Restart(Vec), + /// Restart Buzz so MeshLLM is rebuilt with a freshly resolved roster. + /// + /// MeshLLM's native listeners are process-owned in practice: stopping and + /// starting the embedded runtime in one process can terminate Buzz or race + /// ports 9337/3131. The process boundary is therefore part of the safety + /// contract, not an implementation detail. + RestartProcess, /// Observed a *shrink* (or empty) once. Hold the current allowlist and /// require the same reduced roster on the next poll before tearing down, /// so a single transient short-read never drops a member mid-inference. @@ -224,9 +235,9 @@ fn roster_shrinks(current: &[String], fresh: &[String]) -> bool { /// Rules: /// - query failed (`Err`) → `Keep` (never de-admit on a relay blip) /// - resolved roster == current → `Keep` (no-op) -/// - grows (only additions) → `Restart` immediately (fast admission) +/// - grows (only additions) → `RestartProcess` immediately (fast admission) /// - shrinks/empties, first observation → `AwaitConfirm` (hold, re-check next poll) -/// - shrinks/empties, confirmed → `Restart` (same reduced roster twice) +/// - shrinks/empties, confirmed → `RestartProcess` (same reduced roster twice) fn roster_reconcile_action( current_owners: &[String], pending_shrink: Option<&[String]>, @@ -248,13 +259,13 @@ fn roster_reconcile_action( // Growth (pure additions) is safe to apply immediately. if !roster_shrinks(current_owners, &fresh) { - return RosterReconcileAction::Restart(fresh); + return RosterReconcileAction::RestartProcess; } // A shrink (including down to empty) must be confirmed across two // consecutive polls with the *same* reduced roster before we tear down. match pending_shrink { - Some(pending) if pending == fresh => RosterReconcileAction::Restart(fresh), + Some(pending) if pending == fresh => RosterReconcileAction::RestartProcess, _ => RosterReconcileAction::AwaitConfirm(fresh), } } @@ -283,8 +294,13 @@ async fn reconcile_roster( // other member on a transient relay blip (the flapping restart loop). Keep // the current allowlist and try again on the next poll. A shrink is held // for one extra poll (hysteresis) so a single short-read never tears down. - let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(&state).await; - let fresh = match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) { + let relay_url = current_request + .relay_url + .as_deref() + .map(str::to_owned) + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(&state)); + let query = crate::commands::mesh_llm::resolve_trusted_owner_ids_at(&state, &relay_url).await; + match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) { RosterReconcileAction::Keep => { *pending_shrink = None; return Ok(()); @@ -294,34 +310,12 @@ async fn reconcile_roster( *pending_shrink = Some(reduced); return Ok(()); } - RosterReconcileAction::Restart(fresh) => { + RosterReconcileAction::RestartProcess => { *pending_shrink = None; - fresh } - }; + } - let mut request = current_request.clone(); - request.trusted_owner_ids = Some(fresh); - // Bootstrap endpoints are live device state, not configuration. The - // endpoint used at the previous start may belong to the member that just - // left or to a device whose iroh identity rotated while offline. Resolve a - // fresh validated peer for this restart; starting isolated is safe because - // the join watcher will converge it when a member next publishes. - request.join_token = match crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state) - .await - { - Ok(targets) => targets - .into_iter() - .next() - .map(|target| target.endpoint_addr), - Err(error) => { - eprintln!( - "buzz-mesh: could not refresh bootstrap endpoint for roster restart; starting isolated: {error}" - ); - None - } - }; - let mut guard = state.mesh_llm_runtime.lock().await; + let guard = state.mesh_llm_runtime.lock().await; let startup_pending = match guard.as_ref() { Some(runtime) => runtime.is_starting().await, None => false, @@ -342,24 +336,14 @@ async fn reconcile_roster( // snapshot. return Ok(()); } - let Some(running) = guard.take() else { + if guard.is_none() { return Ok(()); - }; - eprintln!("buzz-mesh: membership roster changed; restarting mesh node with fresh allowlist"); - if let Err(error) = running.stop().await { - drop(guard); - eprintln!( - "buzz-mesh: stopping mesh node for roster restart failed; restarting Buzz instead of racing the occupied ingress: {error}" - ); - app.request_restart(); - return Err(format!( - "mesh node shutdown failed during roster change: {error}" - )); } - let replacement = crate::mesh_llm::DesktopMeshRuntime::start(request) - .await - .map_err(|error| format!("mesh node restart after roster change failed: {error:#}"))?; - *guard = Some(replacement); + drop(guard); + eprintln!( + "buzz-mesh: membership roster changed; restarting Buzz to rebuild MeshLLM with the fresh community allowlist" + ); + app.request_restart(); Ok(()) } @@ -377,11 +361,15 @@ pub(crate) async fn publish_current_status_once(app: &AppHandle, reason: &str) { } } -pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) { +pub(crate) async fn publish_stopped_status_once_at( + app: &AppHandle, + relay_url: Option<&str>, + reason: &str, +) { let state = app.state::(); match tokio::time::timeout( STATUS_PUBLISH_TIMEOUT, - publish_stopped_status_for_state(&state), + publish_stopped_status_for_state(&state, relay_url), ) .await { @@ -396,26 +384,43 @@ pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) { async fn publish_current_status_for_state(state: &AppState) -> Result<(), String> { let identity = super::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; - let mut payload = { + let (mut payload, relay_url) = { let runtime = state.mesh_llm_runtime.lock().await; match runtime.as_ref() { - Some(runtime) => runtime - .status_report_payload() - .await - .map_err(|error| error.to_string())?, - None => stopped_status_payload(&identity), + Some(runtime) => { + let payload = runtime + .status_report_payload() + .await + .map_err(|error| error.to_string())?; + let relay_url = runtime + .start_request() + .relay_url + .clone() + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(state)); + (payload, relay_url) + } + None => ( + stopped_status_payload(&identity), + crate::relay::relay_ws_url_with_override(state), + ), } }; bind_payload_to_member(state, &identity, &mut payload)?; - publish_status_report(state, payload).await + publish_status_report_at(state, &relay_url, payload).await } -async fn publish_stopped_status_for_state(state: &AppState) -> Result<(), String> { +async fn publish_stopped_status_for_state( + state: &AppState, + relay_url: Option<&str>, +) -> Result<(), String> { let identity = super::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; let mut payload = stopped_status_payload(&identity); bind_payload_to_member(state, &identity, &mut payload)?; - publish_status_report(state, payload).await + let relay_url = relay_url + .map(str::to_owned) + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(state)); + publish_status_report_at(state, &relay_url, payload).await } fn stopped_status_payload(identity: &super::identity::OwnerIdentity) -> serde_json::Value { @@ -469,13 +474,21 @@ pub(crate) fn build_status_report_event( .tags([d, k])) } -pub(crate) async fn publish_status_report( +async fn publish_status_report_at( state: &AppState, + relay_url: &str, payload: serde_json::Value, ) -> Result<(), String> { - crate::relay::submit_event(build_status_report_event(payload)?, state) - .await - .map(|_| ()) + let api_base_url = crate::relay::relay_http_base_url(relay_url); + let keys = state.signing_keys()?; + crate::relay::submit_event_at_with_keys( + build_status_report_event(payload)?, + state, + &api_base_url, + &keys, + ) + .await + .map(|_| ()) } #[cfg(test)] @@ -554,11 +567,11 @@ mod tests { // Growth (pure additions) applies immediately — fast admission is fine. #[test] - fn roster_growth_restarts_immediately() { + fn roster_growth_requests_process_restart_immediately() { let current = vec!["owner-a".to_string()]; let fresh = vec!["owner-a".to_string(), "owner-c".to_string()]; - let action = roster_reconcile_action(¤t, None, Ok(fresh.clone())); - assert_eq!(action, RosterReconcileAction::Restart(fresh)); + let action = roster_reconcile_action(¤t, None, Ok(fresh)); + assert_eq!(action, RosterReconcileAction::RestartProcess); } // A shrink is NOT applied on first observation — it must be confirmed. @@ -572,11 +585,11 @@ mod tests { // The same reduced roster on two consecutive polls confirms the shrink. #[test] - fn roster_shrink_restarts_once_confirmed() { + fn roster_shrink_requests_process_restart_once_confirmed() { let current = vec!["owner-a".to_string(), "owner-b".to_string()]; let reduced = vec!["owner-a".to_string()]; let action = roster_reconcile_action(¤t, Some(&reduced), Ok(reduced.clone())); - assert_eq!(action, RosterReconcileAction::Restart(reduced)); + assert_eq!(action, RosterReconcileAction::RestartProcess); } // A shrink that changes between polls is not confirmed — it re-holds with @@ -600,7 +613,7 @@ mod tests { assert_eq!(first, RosterReconcileAction::AwaitConfirm(Vec::new())); let empty: Vec = Vec::new(); let confirmed = roster_reconcile_action(¤t, Some(&empty), Ok(Vec::new())); - assert_eq!(confirmed, RosterReconcileAction::Restart(Vec::new())); + assert_eq!(confirmed, RosterReconcileAction::RestartProcess); } // A shrink followed by recovery to the full roster cancels the teardown. diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 6e3ab4b28..e206c5388 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; mod coordinator; -pub(crate) use coordinator::{publish_current_status_once, publish_stopped_status_once}; +pub(crate) use coordinator::{publish_current_status_once, publish_stopped_status_once_at}; pub use coordinator::{start_coordinator, MeshCoordinator, KIND_BUZZ_MESH_MEMBER_STATUS}; mod discovery; @@ -14,6 +14,7 @@ pub(crate) use discovery::{ use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity}; mod catalog; +pub(crate) use catalog::canonical_curated_model_id; pub use catalog::{model_catalog, MeshModelCatalog}; mod identity; @@ -200,6 +201,11 @@ pub struct StartMeshNodeRequest { /// accepted from the frontend and contains no relay address. #[serde(default, skip_deserializing)] pub mesh_name: Option, + /// Relay this runtime's community membership and discovery are bound to. + /// Injected by the backend when sharing starts and retained across UI + /// workspace switches; moving a share requires an explicit stop/start. + #[serde(default, skip_deserializing)] + pub relay_url: Option, /// Mesh owner ids admitted to this node (the member roster from /// member-signed discovery notes). `None` = caller did not resolve a roster /// (tests, direct invocations): the node runs without allowlist @@ -308,17 +314,20 @@ pub const MESH_WORKER_STACK_SIZE: usize = 8 * 1024 * 1024; /// before the node starts. Without this the download happens *inside* /// `serve::start()` where the UI can only show a frozen "starting…" state. /// Already-installed models return immediately from the cache scan. -async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> { - let model_owned = model.to_string(); - let installed = tokio::task::spawn_blocking(move || { +async fn model_is_installed(model: &str) -> bool { + let model_owned = model.replace("@main", ""); + tokio::task::spawn_blocking(move || { let cache = mesh_llm_node::models::default_huggingface_cache_dir(); mesh_llm_node::models::scan_installed_models(cache) .iter() - .any(|m| m.model_ref.contains(&model_owned)) + .any(|m| m.model_ref.replace("@main", "").contains(&model_owned)) }) .await - .unwrap_or(false); - if installed { + .unwrap_or(false) +} + +async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> { + if model_is_installed(model).await { return Ok(()); } mesh_llm_host_runtime::models::download_model_ref_with_progress_details(model, true) diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index 0b726c264..557cd040f 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -12,6 +12,7 @@ fn pending_client_runtime( max_vram_gb: None, join_token: Some("initial-token".to_string()), mesh_name: None, + relay_url: None, trusted_owner_ids: None, }; super::DesktopMeshRuntime { diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 809fab899..89ca6396e 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -153,6 +153,13 @@ fn should_evict_after_probe( || consecutive >= DEAD_PROBE_EVICT_THRESHOLD } +fn requires_process_restart( + mode: crate::mesh_llm::MeshNodeMode, + startup_in_progress: bool, +) -> bool { + startup_in_progress || mode == crate::mesh_llm::MeshNodeMode::Serve +} + /// Probe and, when justified, remove one stale runtime. A closed port is /// decisive for a foreground agent start; watchdog and ambiguous/unhealthy /// ports require consecutive failures to avoid restarting on a transient load @@ -161,22 +168,23 @@ pub(crate) async fn recover_stale_mesh_runtime( state: &AppState, urgency: MeshRecoveryUrgency, ) -> MeshRuntimeRecovery { - let (candidate_id, startup_in_progress) = match state.mesh_llm_runtime.lock().await.as_ref() { - Some(runtime) => (runtime.id(), runtime.is_starting().await), - None => { - state.mesh_recovery.reset_probe_streak(); - // A cancelled SDK startup can outlive its Buzz-side task briefly - // because the embedded runtime runs on its own thread. Never start - // a replacement merely because the tracked handle is gone: first - // prove the old ingress is either still useful or has released the - // port. This closes the port-conflict loop in #2304. - return match probe_mesh_ingress().await { - MeshIngressProbe::Live => MeshRuntimeRecovery::Live, - MeshIngressProbe::PortClosed => MeshRuntimeRecovery::Absent, - MeshIngressProbe::Unhealthy => MeshRuntimeRecovery::ReleasePending, - }; - } - }; + let (candidate_id, startup_in_progress, candidate_mode) = + match state.mesh_llm_runtime.lock().await.as_ref() { + Some(runtime) => (runtime.id(), runtime.is_starting().await, runtime.mode()), + None => { + state.mesh_recovery.reset_probe_streak(); + // A cancelled SDK startup can outlive its Buzz-side task briefly + // because the embedded runtime runs on its own thread. Never start + // a replacement merely because the tracked handle is gone: first + // prove the old ingress is either still useful or has released the + // port. This closes the port-conflict loop in #2304. + return match probe_mesh_ingress().await { + MeshIngressProbe::Live => MeshRuntimeRecovery::Live, + MeshIngressProbe::PortClosed => MeshRuntimeRecovery::Absent, + MeshIngressProbe::Unhealthy => MeshRuntimeRecovery::ReleasePending, + }; + } + }; let probe = probe_mesh_ingress().await; if probe == MeshIngressProbe::Live { state.mesh_recovery.reset_probe_streak(); @@ -196,12 +204,13 @@ pub(crate) async fn recover_stale_mesh_runtime( return MeshRuntimeRecovery::Debouncing; } - // The pinned SDK does not yield its control handle until the management - // API is ready. Dropping its still-pending start future would detach the - // embedded runtime thread without sending a shutdown request, so Buzz must - // not evict it and race a replacement onto the same ports. A controlled - // app relaunch is the only process-owned cleanup boundary in this state. - if startup_in_progress { + // Never replace a serving runtime in-process. Its native listeners and + // model host are process-owned; stopping it here and then cold-starting a + // client silently disables Share Compute and can race ports 9337/3131. + // Pending client startups have the same ownership problem because the SDK + // has not yielded a shutdown handle yet. In both cases, process restart is + // the only boundary that preserves the configured role safely. + if requires_process_restart(candidate_mode, startup_in_progress) { state.mesh_recovery.reset_probe_streak(); return MeshRuntimeRecovery::RestartRequired; } @@ -241,6 +250,12 @@ pub(crate) async fn recover_stale_mesh_runtime( pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Result<(), String> { let state = app.state::(); let _rearm_guard = state.mesh_recovery.rearm_lock.lock().await; + let runtime_mode = state + .mesh_llm_runtime + .lock() + .await + .as_ref() + .map(|runtime| runtime.mode()); let recovery = recover_stale_mesh_runtime(&state, MeshRecoveryUrgency::Watchdog).await; let active_pubkeys = active_managed_agent_pubkeys(&state); // Mesh participation is resolved through the same definition-authoritative @@ -254,6 +269,13 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu | MeshRuntimeRecovery::Debouncing | MeshRuntimeRecovery::Replaced => return Ok(()), MeshRuntimeRecovery::RestartRequired => { + if runtime_mode == Some(crate::mesh_llm::MeshNodeMode::Serve) { + eprintln!( + "buzz-mesh: serving ingress failed; restarting Buzz to restore Share Compute without changing roles" + ); + app.request_restart(); + return Ok(()); + } let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); if !records.iter().any(|record| { running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() @@ -410,6 +432,7 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, catalog_source: None, @@ -481,6 +504,22 @@ mod tests { assert!(STALE_STOP_TIMEOUT <= Duration::from_secs(15)); } + #[test] + fn failed_serving_runtime_requires_process_restart_instead_of_client_fallback() { + assert!(requires_process_restart( + crate::mesh_llm::MeshNodeMode::Serve, + false + )); + assert!(requires_process_restart( + crate::mesh_llm::MeshNodeMode::Client, + true + )); + assert!(!requires_process_restart( + crate::mesh_llm::MeshNodeMode::Client, + false + )); + } + #[test] fn only_running_relay_mesh_agents_trigger_rearm() { let personas: Vec = Vec::new(); diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index c0cf2e76f..128f2df79 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -24,7 +24,7 @@ type Id = u32; #[derive(Debug, Deserialize)] #[serde(tag = "type", content = "data")] -enum WebSocketMessage { +pub(crate) enum WebSocketMessage { Text(String), Binary(Vec), Ping(Vec), @@ -33,7 +33,7 @@ enum WebSocketMessage { } #[derive(Debug, Deserialize)] -struct CloseFramePayload { +pub(crate) struct CloseFramePayload { code: u16, reason: String, } @@ -82,7 +82,7 @@ struct ConnectionHandle { } #[derive(Clone)] -struct WebSocketManager { +pub(crate) struct WebSocketManager { connections: Arc>>>, connect_cancel: Arc>, } @@ -182,11 +182,23 @@ async fn connect( open_connection(manager.inner(), &url, on_message).await } -async fn send_message( +pub(crate) async fn send_message( manager: &WebSocketManager, id: Id, message: WebSocketMessage, ) -> Result<(), String> { + // Egress guard: the NIP-49 local key backup must never reach a relay. + // This is the single choke point for all webview-originated websocket + // frames (see `crate::egress_guard`). + match &message { + WebSocketMessage::Text(text) => { + crate::egress_guard::assert_no_key_backup(text, "websocket text frame")? + } + WebSocketMessage::Binary(bytes) => { + crate::egress_guard::assert_no_key_backup_bytes(bytes, "websocket binary frame")? + } + _ => {} + } let handle = manager .connections .lock() diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f89669562..71aa21c41 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -450,6 +450,7 @@ pub async fn sync_managed_agent_profile( let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?; let url = format!("{}/events", relay_http_base_url(relay_url)); let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; @@ -566,6 +567,7 @@ pub async fn submit_signed_event_with_keys( crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", relay_api_base_url_with_override(state)); let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; let mut request = state diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 2a42d86c2..eaad29d3b 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -25,6 +25,7 @@ pub async fn submit_signed_event_at_with_keys( crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", api_base_url.trim_end_matches('/')); let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; let response = state diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 90b5bf5eb..44618f2c7 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -56,6 +56,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; +import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; @@ -270,9 +271,18 @@ function AppReady({ } return ( - - - + + void router.navigate({ + to: "/settings", + search: { section: "profile" }, + }) + } + > + + + + ); } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f24a3c06d..3d1673c36 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { OctagonX, Settings2 } from "lucide-react"; +import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, @@ -20,10 +20,7 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; -import { - AGENT_CARD_GRID_COLUMNS_CLASS, - UnifiedAgentsSection, -} from "./UnifiedAgentsSection"; +import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -32,6 +29,12 @@ import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { PageHeader } from "@/shared/ui/PageHeader"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; @@ -44,6 +47,8 @@ export function AgentsView() { const personas = usePersonaActions(); const teamImportInputRef = React.useRef(null); const aiDefaultsTriggerRef = React.useRef(null); + const fullAiDefaultsTriggerRef = React.useRef(null); + const compactActionsTriggerRef = React.useRef(null); const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false); // Exclusivity: create never sets `personaDialogState` (edit/dup/import do), // so the create-mode and definition-edit AgentDialog mounts never coexist. @@ -53,6 +58,22 @@ export function AgentsView() { personas.prepareCreate(); setIsCreateDialogOpen(true); } + + function openAiDefaults(trigger: HTMLButtonElement | null) { + aiDefaultsTriggerRef.current = trigger; + setIsAiDefaultsOpen(true); + } + + function setAiDefaultsDialogOpen(open: boolean) { + if (!open) { + aiDefaultsTriggerRef.current = + fullAiDefaultsTriggerRef.current?.offsetParent !== null + ? fullAiDefaultsTriggerRef.current + : compactActionsTriggerRef.current; + } + setIsAiDefaultsOpen(open); + } + const teamActions = useTeamActions( { setActionNoticeMessage: agents.setActionNoticeMessage, @@ -113,43 +134,84 @@ export function AgentsView() { <>
- - {runningAgentCount > 0 ? ( + <> +
- ) : null} -
+ {runningAgentCount > 0 ? ( + + ) : null} +
+ + + + + + + { + openAiDefaults(compactActionsTriggerRef.current); + }} + > + + {hasSavedAgentDefaults + ? "Agent defaults" + : "Set agent defaults"} + + {runningAgentCount > 0 ? ( + { + void agents.handleBulkStopRunning(); + }} + > + + Stop running agents + + ) : null} + + + } description="Set up and manage your agents." title="Agents" /> -
+
diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index ba76d6e4e..e1ec94609 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; @@ -276,7 +277,42 @@ function PersonaCatalogChooser({ ); } +/** + * Derives the "Added by" label for a catalog entry from a resolved profile + * summary. Prefers `displayName`, falls back to `name`, then to the default + * "Community member" string when both are absent, null, or whitespace-only. + */ +export function resolveCatalogOwnerLabel( + summary: + | { displayName?: string | null; name?: string | null } + | null + | undefined, +): string { + return ( + summary?.displayName?.trim() || summary?.name?.trim() || "Community member" + ); +} + function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { + const isCommunityEntry = + isCatalogPersona(persona) && !persona.catalogSource.isOwn; + const ownerPubkey = isCommunityEntry + ? persona.catalogSource.ownerPubkey + : undefined; + const ownerBatchQuery = useUsersBatchQuery(ownerPubkey ? [ownerPubkey] : [], { + enabled: !!ownerPubkey, + }); + + let addedByLabel: string; + if (!isCommunityEntry) { + addedByLabel = "You"; + } else { + const summary = ownerPubkey + ? ownerBatchQuery.data?.profiles[ownerPubkey.toLowerCase()] + : undefined; + addedByLabel = resolveCatalogOwnerLabel(summary); + } + return (
@@ -290,14 +326,7 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { {persona.displayName} {persona.isBuiltIn ? null : ( - + )}
diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index c641de9c7..5cf4f9ea3 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -37,10 +37,13 @@ import { Dialog, DialogClose, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; import { formatShareRecipientName, @@ -63,7 +66,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { - afterLink?: React.ReactNode; + beforeExport?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -198,7 +201,6 @@ function MemoryShareConfirmation({ function ShareLevelControl({ ariaLabel, disabled, - hasMemoryOptions, testId, value, options, @@ -206,27 +208,11 @@ function ShareLevelControl({ }: { ariaLabel: string; disabled: boolean; - hasMemoryOptions: boolean; testId: string; value: SnapshotMemoryLevel; options: { value: SnapshotMemoryLevel; label: string }[]; onChange: (level: SnapshotMemoryLevel) => void; }) { - if (!hasMemoryOptions) { - // Nothing to choose from, so there is no dropdown to open. State the - // outcome rather than naming the sole option: the memory-level labels - // ("Agent only", "+ core memory", …) are comparative and only make sense - // when the alternatives are actually offered. - return ( - - No memories included - - ); - } - return ( - + Share {displayName} + + Anyone you share this {itemLabel} with will receive a copy they + can add and use. Changes you make later won’t sync. +
-

- They’ll receive a copy they can add and use. Changes you make - later won’t sync. -

+ {hasMemoryOptions ? ( +
+

+ Share settings +

+
+

+ What’s included +

+ +
+
+ ) : null} + + +
- - - -
-

Share with a link

-

- Anyone with the link can add and use a copy. -

-
-
-

- What’s included -

- -
- {showMemoryWarning ? ( ) : null} - - {afterLink}
+ {beforeExport} {!isCollapsed ? ( -
+
{agents.map((agent) => ( { + assert.equal(resolveCatalogOwnerLabel(null), "Community member"); +}); + +test("test_undefined_summary_returns_community_member", () => { + assert.equal(resolveCatalogOwnerLabel(undefined), "Community member"); +}); + +// ── populated displayName ───────────────────────────────────────────────────── + +test("test_display_name_present_returns_display_name", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: "Alice", name: "alice" }), + "Alice", + ); +}); + +test("test_display_name_present_without_name_returns_display_name", () => { + assert.equal(resolveCatalogOwnerLabel({ displayName: "Alice" }), "Alice"); +}); + +// ── empty / whitespace displayName with valid name ──────────────────────────── + +test("test_empty_display_name_falls_through_to_name", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: "", name: "alice" }), + "alice", + ); +}); + +test("test_whitespace_only_display_name_falls_through_to_name", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: " ", name: "alice" }), + "alice", + ); +}); + +// ── both candidates absent / empty ──────────────────────────────────────────── + +test("test_both_null_returns_community_member", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: null, name: null }), + "Community member", + ); +}); + +test("test_both_empty_returns_community_member", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: "", name: "" }), + "Community member", + ); +}); + +test("test_both_whitespace_returns_community_member", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: " ", name: "\t" }), + "Community member", + ); +}); + +test("test_display_name_absent_name_present_returns_name", () => { + assert.equal(resolveCatalogOwnerLabel({ name: "alice" }), "alice"); +}); + +test("test_display_name_null_name_present_returns_name", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: null, name: "alice" }), + "alice", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 92fa172ff..70877875f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -21,10 +21,7 @@ import { getDmHuddleMemberPubkeys, hasOtherDmParticipant, } from "@/features/channels/lib/dmHuddleMembers"; -import { - buildVideoReviewCommentsByRootId, - buildVideoReviewContextForMessage, -} from "@/features/messages/lib/videoReviewContext"; +import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; @@ -150,6 +147,7 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + threadAllMessages, threadHeadMessage, threadMessages, threadMessagesPending = false, @@ -472,25 +470,26 @@ export const ChannelPane = React.memo(function ChannelPane({ threadHeadMessage, threadMessages, }); - const videoReviewCommentsByRootId = React.useMemo( - () => buildVideoReviewCommentsByRootId(messages), - [messages], - ); const activeVideoReviewCommentSender = activeChannel?.archivedAt ? undefined : onSendVideoReviewComment; - const threadHeadVideoReviewContext = React.useMemo(() => { - if (!threadHeadMessage) { - return undefined; + const threadVideoReviewContextsByMessageId = React.useMemo(() => { + const messagesById = new Map( + messages.map((message) => [message.id, message]), + ); + if (threadHeadMessage) { + messagesById.set(threadHeadMessage.id, threadHeadMessage); + } + for (const message of threadAllMessages) { + messagesById.set(message.id, message); } - return buildVideoReviewContextForMessage({ + return buildVideoReviewContextsByMessageId({ channelId: activeChannel?.id ?? null, channelName: activeChannel?.name, channelType: activeChannel?.channelType ?? null, - comments: videoReviewCommentsByRootId.get(threadHeadMessage.id) ?? [], isSendingVideoReviewComment: isSending, - message: threadHeadMessage, + messages: [...messagesById.values()], onSendVideoReviewComment: activeVideoReviewCommentSender, onToggleReaction, profiles, @@ -499,10 +498,11 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, activeVideoReviewCommentSender, isSending, + messages, onToggleReaction, profiles, + threadAllMessages, threadHeadMessage, - videoReviewCommentsByRootId, ]); const isOverlay = useIsThreadPanelOverlay(); @@ -876,7 +876,9 @@ export const ChannelPane = React.memo(function ChannelPane({ scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} threadHead={threadHeadMessage} - threadHeadVideoReviewContext={threadHeadVideoReviewContext} + videoReviewContextsByMessageId={ + threadVideoReviewContextsByMessageId + } widthPx={threadPanelWidthPx} threadReplies={threadMessages} threadRepliesPending={threadMessagesPending} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5a27d85c4..7257d8cd5 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -151,6 +151,7 @@ export type ChannelPaneProps = { profilePanelTab: ProfilePanelTab; profilePanelView: ProfilePanelView; threadHeadMessage: TimelineMessage | null; + threadAllMessages: TimelineMessage[]; threadMessages: MainTimelineEntry[]; threadMessagesPending?: boolean; threadPanelWidthPx: number; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 52b3ae7fb..7b750daa4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -691,6 +691,7 @@ export function ChannelScreen({ channelManagementOpen, ); const displayedThreadHeadMessage = threadPanelData.threadHead; + const displayedThreadAllMessages = threadPanelData.messages; const displayedThreadMessages = threadPanelData.visibleReplies; const displayedThreadReplyTargetMessage = threadPanelData.replyTargetMessage; const displayedThreadFirstUnreadReplyId = displayedThreadHeadMessage @@ -944,6 +945,7 @@ export function ChannelScreen({ firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} threadHeadMessage={displayedThreadHeadMessage} threadMessages={displayedThreadMessages} threadMessagesPending={threadRepliesQuery.isPending} diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index e05e2ba0b..6ea091641 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -18,6 +18,7 @@ import { } from "./useUnreadChannels.ts"; import { isChannelUnreadTriggerKind, + trackSeenEvent, withChannelTagFallback, } from "./useLiveChannelUpdates.ts"; import { @@ -90,6 +91,16 @@ test("live event with h tag is preserved", () => { assert.equal(withChannelTagFallback(message, "other-channel"), message); }); +test("notification event guard suppresses reconnect replay and stays bounded", () => { + const seen = new Set(); + + assert.equal(trackSeenEvent(seen, "event-a", 2), true); + assert.equal(trackSeenEvent(seen, "event-a", 2), false); + assert.equal(trackSeenEvent(seen, "event-b", 2), true); + assert.equal(trackSeenEvent(seen, "event-c", 2), true); + assert.deepEqual([...seen], ["event-b", "event-c"]); +}); + test("dmHuddleStart_isDmOnlyUnreadTrigger", () => { assert.equal( isChannelUnreadTriggerKind(KIND_HUDDLE_STARTED, true), diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index aeb3abb90..800467b6e 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -110,13 +110,19 @@ function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { ); } -function trackSeenEvent(seenEventIds: Set, eventId: string): boolean { +const SEEN_NOTIFICATION_EVENT_LIMIT = 5_000; + +export function trackSeenEvent( + seenEventIds: Set, + eventId: string, + limit = 200, +): boolean { if (seenEventIds.has(eventId)) { return false; } seenEventIds.add(eventId); - if (seenEventIds.size > 200) { + if (seenEventIds.size > limit) { const oldestEventId = seenEventIds.values().next().value; if (oldestEventId) { seenEventIds.delete(oldestEventId); @@ -135,6 +141,11 @@ export function useLiveChannelUpdates( const normalizedCurrentPubkey = options.currentPubkey?.trim().toLowerCase() ?? ""; const seenMentionEventIdsRef = React.useRef(new Set()); + // Reconnect replay overlaps each live filter by five seconds so no message is + // lost at the boundary. Keep one shared guard for every notification side + // effect: the same event can be replayed repeatedly while a relay flaps, and + // mention events also arrive through both the channel and mention filters. + const seenNotificationEventIdsRef = React.useRef(new Set()); const channelsInvalidateRef = React.useRef(null); if (channelsInvalidateRef.current === null) { channelsInvalidateRef.current = createTrailingDebounce(() => { @@ -164,7 +175,6 @@ export function useLiveChannelUpdates( ), [channels], ); - const seenDmEventIdsRef = React.useRef(new Set()); const dmSubscriptionStartedAtRef = React.useRef(0); // Reset subscription timestamp when identity changes. @@ -181,44 +191,42 @@ export function useLiveChannelUpdates( [channels], ); - const handleDmEvent = React.useEffectEvent((event: RelayEvent) => { - // Only human-visible message kinds should fire DM notifications. - if (!isDmNotifiableKind(event.kind)) { - return; - } + const handleDmEvent = React.useEffectEvent( + (event: RelayEvent, isFirstNotificationDelivery: boolean) => { + // Only human-visible message kinds should fire DM notifications. + if (!isDmNotifiableKind(event.kind) || !isFirstNotificationDelivery) { + return; + } - // Suppress backlog events that predate our subscription — these are - // historical replays, not live messages. - if (event.created_at < dmSubscriptionStartedAtRef.current) { - return; - } + // Suppress backlog events that predate our subscription — these are + // historical replays, not live messages. + if (event.created_at < dmSubscriptionStartedAtRef.current) { + return; + } - const channelId = getChannelIdFromTags(event.tags); - if (!channelId) { - return; - } + const channelId = getChannelIdFromTags(event.tags); + if (!channelId) { + return; + } - if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { - return; - } + if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { + return; + } - const dmChannel = dmChannelMap.get(channelId); - if (!dmChannel) { - return; - } + const dmChannel = dmChannelMap.get(channelId); + if (!dmChannel) { + return; + } - if (!trackSeenEvent(seenDmEventIdsRef.current, event.id)) { - return; - } + // Don't fire a notification for the channel the user is already viewing, + // unless the notify-while-viewing setting opts in. + if (channelId === activeChannelId && !options.notifyForActiveChannel) { + return; + } - // Don't fire a notification for the channel the user is already viewing, - // unless the notify-while-viewing setting opts in. - if (channelId === activeChannelId && !options.notifyForActiveChannel) { - return; - } - - options.onDmMessage?.(event, dmChannel); - }); + options.onDmMessage?.(event, dmChannel); + }, + ); const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { const channelId = getChannelIdFromTags(event.tags); @@ -226,12 +234,6 @@ export function useLiveChannelUpdates( return; } - // Track DM events even for the active channel so the dedup set stays - // current. The handler itself skips firing the notification callback - // when the user is already viewing the DM (unless opted in via - // notifyForActiveChannel). - handleDmEvent(event); - if (!liveChannelIds.has(channelId)) { if (channelId !== activeChannelId) { invalidateChannelsDebounced(); @@ -263,9 +265,21 @@ export function useLiveChannelUpdates( isUnreadTriggerKind && (normalizedCurrentPubkey.length === 0 || event.pubkey.toLowerCase() !== normalizedCurrentPubkey); + const isFirstNotificationDelivery = + !isExternalTriggerEvent || + trackSeenEvent( + seenNotificationEventIdsRef.current, + event.id, + SEEN_NOTIFICATION_EVENT_LIMIT, + ); const isThreadedReply = isThreadReply(event.tags); - if (isExternalTriggerEvent) { + // DM alerts and every other notification side effect share this delivery + // decision, preventing a replayed event from escaping through a second + // callback path. + handleDmEvent(event, isFirstNotificationDelivery); + + if (isExternalTriggerEvent && isFirstNotificationDelivery) { const shouldNotify = shouldNotifyForEvent( event, normalizedCurrentPubkey, diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index 76f86f805..fd7550eff 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -107,7 +107,9 @@ export function MeshComputeSettingsCard() { // One-shot hardware-aware catalog fetch. Purely additive: when it fails // (stub build, survey error) the card falls back to the free-text field. - // Keep an empty draft empty so the UI can explicitly ask the member to choose. + // When there is no saved choice, make the curated recommendation the actual + // default so a new member can turn Share Compute on directly. An explicit + // saved draft always wins. React.useEffect(() => { let cancelled = false; (async () => { @@ -115,6 +117,11 @@ export function MeshComputeSettingsCard() { const value = await meshModelCatalog(); if (cancelled) return; setCatalog(value); + setModelInput((current) => { + if (current.trim() !== "" || !value.recommended) return current; + writeDraft(MODEL_DRAFT_STORAGE_KEY, value.recommended); + return value.recommended; + }); } catch { // Non-fatal — picker just doesn't render. } diff --git a/desktop/src/features/messages/lib/independentThreadPanel.ts b/desktop/src/features/messages/lib/independentThreadPanel.ts index 4562928d8..7652c2508 100644 --- a/desktop/src/features/messages/lib/independentThreadPanel.ts +++ b/desktop/src/features/messages/lib/independentThreadPanel.ts @@ -11,16 +11,18 @@ export function buildIndependentThreadPanel( ...formatArgs: Tail> ) { if (!rootId) { - return buildThreadPanelData([], null, replyTargetId, expandedReplyIds); + return { + ...buildThreadPanelData([], null, replyTargetId, expandedReplyIds), + messages: [], + }; } const head = channelEvents.find((event) => event.id === rootId); const events = head ? [head, ...replyEvents] : replyEvents; - return buildThreadPanelData( - formatTimelineMessages(events, ...formatArgs), - rootId, - replyTargetId, - expandedReplyIds, - ); + const messages = formatTimelineMessages(events, ...formatArgs); + return { + ...buildThreadPanelData(messages, rootId, replyTargetId, expandedReplyIds), + messages, + }; } type Tail = T extends readonly [ diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index dc33da4f8..f17a53c66 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -23,6 +23,13 @@ test("estimateRowHeight: short text is near the floor", () => { assert.ok(h >= 60 && h < 120, `expected small, got ${h}`); }); +test("estimateRowHeight: continuation reserves its uniform padding", () => { + const h = estimateRowHeight(msg({ body: "hello" }), { + isContinuation: true, + }); + assert.equal(h, 28); +}); + test("estimateRowHeight: many lines reserve more", () => { const tall = estimateRowHeight( msg({ body: Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n") }), diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index f2fb26816..acefae95d 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -26,13 +26,13 @@ const TEXT_LINE_HEIGHT = 20; const CODE_LINE_HEIGHT = 19; const CHARS_PER_LINE = 64; // rough wrap width at the timeline column const ROW_CHROME = 26; // author/time header + denser row padding -const CONTINUATION_ROW_CHROME = 8; // dense row padding only; header/avatar are hidden +const CONTINUATION_ROW_CHROME = 8; // uniform py-1 padding; header/avatar are hidden const MEDIA_BLOCK_MARGIN_TOP = 4; // image/video blocks use mt-1 in markdown const REACTION_ROW = 24; const PREVIEW_CARD = 70; const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5 const MIN_ESTIMATE = 60; // never reserve less than the old flat floor -const CONTINUATION_MIN_ESTIMATE = 34; +const CONTINUATION_MIN_ESTIMATE = 28; function mediaHeightFromDim(dim: string | undefined): number { const dimensions = dimensionsFromDim(dim); diff --git a/desktop/src/features/messages/lib/videoReviewContext.test.mjs b/desktop/src/features/messages/lib/videoReviewContext.test.mjs index de35d53ce..8ecb5f579 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.test.mjs +++ b/desktop/src/features/messages/lib/videoReviewContext.test.mjs @@ -5,6 +5,7 @@ import { buildVideoReviewCommentsByRootId, buildVideoReviewCommentsForRoot, buildVideoReviewContextForMessage, + buildVideoReviewContextsByMessageId, hasVideoAttachment, } from "./videoReviewContext.ts"; @@ -209,3 +210,30 @@ test("buildVideoReviewContextForMessage posts against the source video", async ( }, ]); }); + +test("buildVideoReviewContextsByMessageId includes video replies", () => { + const root = message({ id: "root", body: "Review request" }); + const videoReply = message({ + id: "video-reply", + body: "![video](https://relay/media/a.mp4)", + parentId: root.id, + rootId: root.id, + }); + const comment = message({ + id: "comment", + body: "[00:01] tighten this", + parentId: videoReply.id, + rootId: root.id, + }); + + const contexts = buildVideoReviewContextsByMessageId({ + channelId: "channel", + messages: [root, videoReply, comment], + }); + + assert.deepEqual([...contexts.keys()], [videoReply.id]); + assert.deepEqual( + contexts.get(videoReply.id)?.comments.map((item) => item.id), + [comment.id], + ); +}); diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index 8d0798db4..f605952f5 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -148,3 +148,48 @@ export function buildVideoReviewContextForMessage({ rootEventId: message.id, }; } + +export function buildVideoReviewContextsByMessageId({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment = false, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, +}: { + channelId?: string | null; + channelName?: string; + channelType?: ChannelType | null; + isSendingVideoReviewComment?: boolean; + messages: TimelineMessage[]; + onSendVideoReviewComment?: SendVideoReviewComment; + onToggleReaction?: ToggleMessageReaction; + profiles?: UserProfileLookup; +}): ReadonlyMap { + const contexts = new Map(); + if (!messages.some(hasVideoAttachment)) { + return contexts; + } + + const commentsByRootId = buildVideoReviewCommentsByRootId(messages); + for (const message of messages) { + const context = buildVideoReviewContextForMessage({ + channelId, + channelName, + channelType, + comments: commentsByRootId.get(message.id) ?? [], + isSendingVideoReviewComment, + message, + onSendVideoReviewComment, + onToggleReaction, + profiles, + }); + if (context) { + contexts.set(message.id, context); + } + } + + return contexts; +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 069232fde..688b5d5f0 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -433,8 +433,8 @@ export const MessageRow = React.memo(
@@ -756,6 +758,9 @@ export function MessageThreadPanel({ onToggleReaction={onToggleReaction} profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} + videoReviewContext={videoReviewContextsByMessageId?.get( + entry.message.id, + )} /> {entry.summary ? ( - messages.some(hasVideoAttachment) - ? buildVideoReviewCommentsByRootId(messages) - : new Map(), - [messages], - ); // Contexts are memoized per message id so MessageRow/Markdown memo // comparisons hold across unrelated timeline re-renders (typing // indicators, presence updates) — a fresh context object per render would // defeat the memo and re-render every video message on every pass. const videoReviewContextById = React.useMemo(() => { - const contexts = new Map< - string, - NonNullable> - >(); - for (const message of messages) { - const comments = reviewCommentsByRootId.get(message.id) ?? []; - const context = buildVideoReviewContextForMessage({ - channelId, - channelName, - channelType, - comments, - isSendingVideoReviewComment, - message, - onSendVideoReviewComment, - onToggleReaction, - profiles, - }); - if (context) { - contexts.set(message.id, context); - } - } - return contexts; + return buildVideoReviewContextsByMessageId({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + }); }, [ channelId, channelName, @@ -213,7 +191,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onSendVideoReviewComment, onToggleReaction, profiles, - reviewCommentsByRootId, ]); // The flattened item stream, memoized on the entries and the unread boundary diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 4b729ab33..98210c57c 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -160,6 +160,7 @@ export function CommunityOnboardingFlow({ [], ); const [isPending, setIsPending] = React.useState(false); + const checkedProfileTransactionRef = React.useRef(null); const [starterChannelFailureCount, setStarterChannelFailureCount] = React.useState(0); const [deniedPubkey, setDeniedPubkey] = React.useState(""); @@ -283,6 +284,22 @@ export function CommunityOnboardingFlow({ }, [isPending, update]); const isProfileStage = transaction?.stage === "profile"; + React.useEffect(() => { + if (!isProfileStage || !transaction) return; + if (checkedProfileTransactionRef.current === transaction.id) return; + + checkedProfileTransactionRef.current = transaction.id; + void getProfile() + .then((profile) => { + if (profile.hasProfileEvent) { + update({ stage: "team-intro", error: undefined }, transaction.id); + } + }) + .catch(() => { + // Discovery is best-effort. Staying on the profile step preserves the + // existing path when the relay cannot answer the lookup. + }); + }, [isProfileStage, transaction, update]); const isTeamStage = transaction?.stage === "team-intro" || transaction?.stage === "finalizing" || diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx index 111bdefd7..26f538da5 100644 --- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx +++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx @@ -1,7 +1,23 @@ -import { Check, Copy, Eye, EyeOff } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, MoreHorizontal } from "lucide-react"; import * as React from "react"; import { Button } from "@/shared/ui/button"; -import { writeTextToClipboard } from "@/shared/lib/clipboard"; +import { + copyTextToClipboard, + writeTextToClipboard, +} from "@/shared/lib/clipboard"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +type NsecAction = { + icon?: React.ReactNode; + label: string; + onSelect: () => void; + testId?: string; +}; type NsecMaskedDisplayProps = { nsec: string; @@ -12,6 +28,8 @@ type NsecMaskedDisplayProps = { * a backup (e.g. sign-out) gate on actual interaction with the key. */ onKeyInteraction?: () => void; + /** Replaces the copy icon with an overflow menu containing Copy plus these actions. */ + actions?: readonly NsecAction[]; }; export const ONBOARDING_KEY_FRAME_CLASS = @@ -31,6 +49,7 @@ export function NsecMaskedDisplay({ nsec, variant = "boxed", onKeyInteraction, + actions, }: NsecMaskedDisplayProps) { const [isRevealed, setIsRevealed] = React.useState(false); const [isCopied, setIsCopied] = React.useState(false); @@ -58,6 +77,11 @@ export function NsecMaskedDisplay({ copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000); } + function handleMenuCopy() { + copyTextToClipboard(nsec); + onKeyInteraction?.(); + } + const isBare = variant === "bare"; // Mask every character (no plaintext prefix leak), matching the real key's // length so toggling reveal never reflows the monospace text (no layout shift). @@ -121,24 +145,60 @@ export function NsecMaskedDisplay({
diff --git a/desktop/src/features/settings/EncryptedBackupProvider.tsx b/desktop/src/features/settings/EncryptedBackupProvider.tsx new file mode 100644 index 000000000..7375a85cf --- /dev/null +++ b/desktop/src/features/settings/EncryptedBackupProvider.tsx @@ -0,0 +1,251 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + createNcryptsecBackup, + saveNcryptsecCopy, +} from "@/shared/api/tauriIdentity"; +import { + type EncryptedBackupEvent, + type EncryptedBackupState, + encryptedBackupReducer, + initialEncryptedBackupState, + pendingEncryptPassphrase, +} from "./lib/encryptedBackup"; + +const ENCRYPT_DEBOUNCE_MS = 400; +/** How long a completed encrypted backup remains available in memory. */ +export const BACKUP_AVAILABILITY_MS = 5 * 60 * 1000; +const BACKUP_READY_TOAST_ID = "encrypted-key-backup-ready"; + +type EncryptedBackupContextValue = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + backupAvailable: boolean; + availableUntil: number | null; + isSaving: boolean; + saveError: string | null; + downloadBackup: () => Promise; + startNewBackup: () => void; +}; + +const EncryptedBackupContext = + React.createContext(null); + +export function EncryptedBackupProvider({ + children, + onOpenSettings, +}: { + children: React.ReactNode; + onOpenSettings: () => void; +}) { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [availableUntil, setAvailableUntil] = React.useState( + null, + ); + const [isSaving, setIsSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const autoSaveStartedForRef = React.useRef(null); + const mountedRef = React.useRef(true); + const onOpenSettingsRef = React.useRef(onOpenSettings); + + React.useEffect(() => { + onOpenSettingsRef.current = onOpenSettings; + }, [onOpenSettings]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + React.useEffect(() => { + if (availableUntil === null) return; + const expiresIn = Math.max(0, availableUntil - Date.now()); + const timer = window.setTimeout(() => { + autoSaveStartedForRef.current = null; + setAvailableUntil(null); + setSaveError(null); + dispatch({ type: "start-new-backup" }); + toast.dismiss(BACKUP_READY_TOAST_ID); + }, expiresIn); + return () => window.clearTimeout(timer); + }, [availableUntil]); + + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let started = false; + let cancelledBeforeStart = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelledBeforeStart) return; + started = true; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => { + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }); + }) + .catch((err: unknown) => { + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }); + }); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + if (!started) cancelledBeforeStart = true; + window.clearTimeout(timer); + }; + }, [pendingPassphrase, skipDebounce, state.nextRequestId]); + + React.useEffect(() => { + if (state.downloadPending) { + toast.loading("Preparing backup…", { + description: "You can close this window while Buzz finishes.", + duration: Number.POSITIVE_INFINITY, + id: BACKUP_READY_TOAST_ID, + }); + return; + } + if ( + state.createError && + state.passphrase.length === 0 && + !state.ncryptsec + ) { + toast.error("Couldn’t create backup", { + description: state.createError, + id: BACKUP_READY_TOAST_ID, + }); + } + }, [ + state.createError, + state.downloadPending, + state.ncryptsec, + state.passphrase.length, + ]); + + const showAvailableToast = React.useCallback( + (description: string, error = false) => { + const options = { + action: { + label: "Open settings", + onClick: () => onOpenSettingsRef.current(), + }, + description, + id: BACKUP_READY_TOAST_ID, + }; + if (error) toast.error("Backup ready to download", options); + else toast.success("Backup ready to download", options); + }, + [], + ); + + const saveBackup = React.useCallback( + async (ncryptsec: string) => { + if (isSaving) return; + setIsSaving(true); + setSaveError(null); + toast("Saving backup…", { + description: "The download window will open when it’s ready.", + id: BACKUP_READY_TOAST_ID, + }); + try { + const path = await saveNcryptsecCopy(ncryptsec); + if (mountedRef.current) { + showAvailableToast( + path === null + ? "Your backup will be available to download for 5 minutes." + : "You can download another copy for 5 minutes.", + ); + } + } catch (err) { + if (!mountedRef.current) return; + const message = + err instanceof Error ? err.message : "Failed to save your key."; + setSaveError(message); + showAvailableToast( + `${message} It will be available to download for 5 minutes.`, + true, + ); + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, + [isSaving, showAvailableToast], + ); + + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || autoSaveStartedForRef.current === ncryptsec) return; + autoSaveStartedForRef.current = ncryptsec; + setAvailableUntil(Date.now() + BACKUP_AVAILABILITY_MS); + void saveBackup(ncryptsec); + }, [saveBackup, state.ncryptsec]); + + const downloadBackup = React.useCallback(async () => { + if (!state.ncryptsec) return; + await saveBackup(state.ncryptsec); + }, [saveBackup, state.ncryptsec]); + + const startNewBackup = React.useCallback(() => { + autoSaveStartedForRef.current = null; + setAvailableUntil(null); + setSaveError(null); + toast.dismiss(BACKUP_READY_TOAST_ID); + dispatch({ type: "start-new-backup" }); + }, []); + + const value = React.useMemo( + () => ({ + state, + dispatch, + backupAvailable: + state.savedPassword && + state.ncryptsec !== null && + availableUntil !== null, + availableUntil, + isSaving, + saveError, + downloadBackup, + startNewBackup, + }), + [ + availableUntil, + downloadBackup, + isSaving, + saveError, + startNewBackup, + state, + ], + ); + + return ( + + {children} + + ); +} + +export function useEncryptedBackup(): EncryptedBackupContextValue { + const value = React.useContext(EncryptedBackupContext); + if (!value) { + throw new Error( + "useEncryptedBackup must be used within EncryptedBackupProvider", + ); + } + return value; +} diff --git a/desktop/src/features/settings/lib/encryptedBackup.test.mjs b/desktop/src/features/settings/lib/encryptedBackup.test.mjs new file mode 100644 index 000000000..306e937ec --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; +const reduce = (events, from = initialEncryptedBackupState) => + events.reduce(encryptedBackupReducer, from); +test("password validation mirrors Rust character counting", () => { + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + assert.equal( + effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])), + emoji, + ); +}); +test("valid password requests encryption without copying it into events", () => { + const ready = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + ]); + assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four"); + const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready); + assert.equal(isEncrypting(started), true); + assert.equal(started.requestId, 1); + assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); +}); +test("background success retains the password without committing the download", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.passphrase, "one-two-three-four"); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.ncryptsec, null); + assert.equal(state.savedPassword, false); + assert.equal(state.requestId, null); + assert.equal(downloadDisabled(state), false); +}); +test("submit commits a completed preload immediately", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("stale async completions cannot replace current request", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + { type: "encrypt-started", requestId: 2 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" }, + ]); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); +}); +test("failure clears submitted password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.createError, "keychain unavailable"); + assert.equal(state.downloadPending, false); + assert.equal(downloadDisabled(state), true); +}); +test("background failure stays silent until submit retries encryption", () => { + const failed = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(failed.passphrase, "one-two-three-four"); + assert.equal(failed.createError, "keychain unavailable"); + assert.equal(pendingEncryptPassphrase(failed), null); + + const retrying = reduce([{ type: "download-clicked" }], failed); + assert.equal(retrying.createError, null); + assert.equal(retrying.downloadPending, true); + assert.equal(pendingEncryptPassphrase(retrying), "one-two-three-four"); +}); +test("queued download commits and clears password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("starting over discards blob and invalidates late requests", () => { + const made = { + ...initialEncryptedBackupState, + ncryptsec: "ncryptsec1abc", + encrypted: "ncryptsec1abc", + savedPassword: true, + nextRequestId: 3, + }; + const fresh = reduce([{ type: "start-new-backup" }], made); + assert.equal(fresh.ncryptsec, null); + assert.equal(fresh.nextRequestId, 4); + assert.equal( + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, + ); +}); diff --git a/desktop/src/features/settings/lib/encryptedBackup.ts b/desktop/src/features/settings/lib/encryptedBackup.ts new file mode 100644 index 000000000..e54189769 --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.ts @@ -0,0 +1,143 @@ +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; + createError: string | null; + downloadPending: boolean; + ncryptsec: string | null; + savedPassword: boolean; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, + createError: null, + downloadPending: false, + ncryptsec: null, + savedPassword: false, +}; + +export type EncryptedBackupEvent = + | { type: "set-passphrase"; value: string } + | { type: "encrypt-started"; requestId: number } + | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } + | { type: "encrypt-failed"; requestId: number; message: string } + | { type: "download-clicked" } + | { type: "start-new-backup" }; + +export function encryptedBackupReducer( + state: EncryptedBackupState, + event: EncryptedBackupEvent, +): EncryptedBackupState { + switch (event.type) { + case "set-passphrase": + return { + ...state, + passphrase: event.value, + requestId: null, + encrypted: null, + createError: null, + }; + case "encrypt-started": + return { + ...state, + requestId: event.requestId, + nextRequestId: Math.max(state.nextRequestId, event.requestId + 1), + createError: null, + }; + case "encrypt-succeeded": + if (event.requestId !== state.requestId) return state; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: event.ncryptsec, + downloadPending: false, + savedPassword: true, + } + : { + ...state, + requestId: null, + encrypted: event.ncryptsec, + }; + case "encrypt-failed": + if (event.requestId !== state.requestId) return state; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + } + : { + ...state, + requestId: null, + createError: event.message, + }; + case "download-clicked": + if ( + state.ncryptsec || + state.downloadPending || + (!state.encrypted && !effectivePassphrase(state)) + ) + return state; + return state.encrypted + ? { + ...state, + ncryptsec: state.encrypted, + passphrase: "", + savedPassword: true, + } + : { ...state, createError: null, downloadPending: true }; + case "start-new-backup": + return { + ...initialEncryptedBackupState, + nextRequestId: state.nextRequestId + 1, + }; + } +} + +export function passphraseIssue(passphrase: string): string | null { + if (passphrase.length === 0) return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; +} +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + return [...state.passphrase].length < MIN_PASSPHRASE_LEN + ? null + : state.passphrase; +} +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if ( + state.savedPassword || + state.encrypted || + state.requestId !== null || + (state.createError !== null && !state.downloadPending) + ) + return null; + return effectivePassphrase(state); +} +export function isEncrypting(state: EncryptedBackupState): boolean { + return state.requestId !== null; +} +export function downloadDisabled(state: EncryptedBackupState): boolean { + if (state.savedPassword && state.ncryptsec) return false; + return ( + state.downloadPending || + (!state.encrypted && effectivePassphrase(state) === null) + ); +} diff --git a/desktop/src/features/settings/ui/BackupTestFlow.tsx b/desktop/src/features/settings/ui/BackupTestFlow.tsx new file mode 100644 index 000000000..65ef40a98 --- /dev/null +++ b/desktop/src/features/settings/ui/BackupTestFlow.tsx @@ -0,0 +1,459 @@ +import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; + +type BackupTestStage = "drop" | "password" | "success"; + +/** + * Progress through the Settings backup-test flow. The password attempt is + * deliberately NOT part of this state — it lives only in short-lived + * moment it's submitted or the component unmounts. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** Contents of the accepted file, pending or past verification. */ + ncryptsec: string | null; + /** The Rust-verified public identity once decryption succeeded. */ + result: BackupVerification | null; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + ncryptsec: null, + result: null, +}; + +type BackupTestFlowProps = { + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; + +type BurstParticle = { + id: number; + x: number; + y: number; + emoji: string; + delay: number; + scale: number; + rotate: number; +}; + +/** + * One-shot radial emoji burst behind the success badge. Purely decorative — + * skipped entirely under reduced motion. + */ +function SuccessBurst() { + const particles = React.useMemo( + () => + Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => { + const angle = + (i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5; + const distance = 70 + Math.random() * 80; + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + emoji: BURST_EMOJIS[i % BURST_EMOJIS.length], + delay: Math.random() * 0.18, + scale: 0.8 + Math.random() * 0.7, + rotate: -120 + Math.random() * 240, + }; + }), + [], + ); + + return ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +/** + * "Test your backup" flow: the user drops a backup file onto a large + * dropzone, then enters its password. Verification is a real NIP-49 decrypt + * in Rust — the submitted password is cleared immediately after the result + * and only the derived public identity ever comes back. + */ +export function BackupTestFlow({ + progress, + onProgressChange, +}: BackupTestFlowProps) { + const reduceMotion = useReducedMotion() ?? false; + const { stage, fileName, ncryptsec, result } = progress; + // True while a file drag is anywhere over the window — the drop overlay + // takes over the host surface only for the duration of the drag. + const [isWindowDragging, setIsWindowDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); + + React.useEffect(() => { + // dragenter/dragleave fire per nested element, so track depth to know + // when the drag has actually left the window. + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsWindowDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsWindowDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsWindowDragging(false); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + // The password attempt is component-local, never host state: it is cleared + // when verification is submitted and when this component unmounts. + const [attempt, setAttempt] = React.useState(""); + const [error, setError] = React.useState(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Opaque correlation id so a stale in-flight verification can't commit + // after "Use a different file" or unmount. + const requestRef = React.useRef(0); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestRef.current += 1; + setAttempt(""); + }; + }, []); + + React.useEffect(() => { + if (stage === "password") passwordInputRef.current?.focus(); + }, [stage]); + + const handleFile = React.useCallback( + async (file: File) => { + let text: string; + try { + text = (await file.text()).trim(); + } catch { + if (mountedRef.current) setError("Could not read that file."); + return; + } + if (!mountedRef.current) return; + if (!text.toLowerCase().startsWith("ncryptsec1")) { + setError("That doesn't look like a key backup file."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [onProgressChange], + ); + + const handleVerify = React.useCallback(async () => { + if (!ncryptsec || !attempt || isVerifying) return; + const password = attempt; + const requestId = ++requestRef.current; + setIsVerifying(true); + setError(null); + setIsRevealed(false); + // Clear the attempt the moment it's handed to Rust — success or failure, + // the typed password never lingers in the field. + setAttempt(""); + try { + const verified = await verifyNcryptsecBackup(ncryptsec, password); + if (!mountedRef.current || requestId !== requestRef.current) return; + onProgressChange((prev) => ({ + ...prev, + stage: "success", + result: verified, + })); + } catch (err) { + if (mountedRef.current && requestId === requestRef.current) + setError( + err instanceof Error ? err.message : "Could not verify this backup.", + ); + } finally { + if (mountedRef.current && requestId === requestRef.current) + setIsVerifying(false); + } + }, [attempt, isVerifying, ncryptsec, onProgressChange]); + + if (stage === "success" && result) { + return ( +
+ {reduceMotion ? null : } + + + +

+ This backup works +

+

+ {result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+
+ +
+
+ +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + <> + { + const file = event.target.files?.[0]; + // Allow re-selecting the same file after an error. + event.target.value = ""; + if (file) void handleFile(file); + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> + + {isWindowDragging ? ( + /* + * Composer-style takeover: fills the nearest positioned host + * surface (the settings backup row) and is + * itself the drop target, so anywhere on that surface accepts + * the file. + */ + // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + ) : ( + <> +
+
+

+ That's the one. Now enter your password to prove you can unlock it. +

+
+ setAttempt(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleVerify(); + } + }} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + +
+ + )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx new file mode 100644 index 000000000..fb20eb9c6 --- /dev/null +++ b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx @@ -0,0 +1,375 @@ +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; +import * as React from "react"; + +import { generateBackupPassphrase } from "@/shared/api/tauriIdentity"; +import { useEncryptedBackup } from "@/features/settings/EncryptedBackupProvider"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { downloadDisabled, MIN_PASSPHRASE_LEN } from "../lib/encryptedBackup"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Indeterminate KDF progress. Scrypt does not expose intermediate progress, + * so randomized increments consume a shrinking fraction of the remaining + * distance. The bar moves quickly at first and can never reach completion. + */ +function FakeKdfProgressBar() { + const [progress, setProgress] = React.useState(0); + + React.useEffect(() => { + let animationFrame = 0; + let nextAdvanceAt = 0; + const advance = (now: number) => { + if (now >= nextAdvanceAt) { + setProgress((current) => { + const remaining = 90 - current; + const fraction = 0.08 + Math.random() * 0.22; + return Math.min(90, current + Math.max(0.25, remaining * fraction)); + }); + nextAdvanceAt = now + 180 + Math.random() * 420; + } + animationFrame = window.requestAnimationFrame(advance); + }; + animationFrame = window.requestAnimationFrame(advance); + return () => window.cancelAnimationFrame(animationFrame); + }, []); + + return ( +
+
+
+ ); +} + +/** + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow for Settings. The raw private + * key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly; clicking mid-encryption queues the + * download until the KDF finishes. + */ +export function EncryptedBackupCreator({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const { state, dispatch, isSaving, saveError } = useEncryptedBackup(); + const [isRevealed, setIsRevealed] = React.useState(false); + + // A queued download hides the form; mask the password before it can return + // in any error state. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + React.useEffect(() => { + if (state.ncryptsec) onOpenChange(false); + }, [onOpenChange, state.ncryptsec]); + + return ( + + + + Create a key backup + + You can close this window while Buzz finishes the backup in the + background. + + +
+ {state.downloadPending ? ( + + ) : !state.savedPassword ? ( +
+ + dispatch({ + type: "set-passphrase", + value: event.target.value, + }) + } + placeholder={`Password (min ${MIN_PASSPHRASE_LEN} characters)`} + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + + { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + /> +
+ ) : null} + + {!state.downloadPending && !state.savedPassword ? ( +

+ Keep the file private and save its password somewhere safe — Buzz + cannot reset it. Once ready, the backup remains available to + download for 5 minutes. +

+ ) : null} + + {state.createError && state.passphrase.length === 0 ? ( +

+ {state.createError} +

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

+ {saveError} +

+ ) : null} + + {!state.downloadPending ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx new file mode 100644 index 000000000..8eb0256a5 --- /dev/null +++ b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx @@ -0,0 +1,215 @@ +import { Download, Eye, EyeOff, ShieldCheck } from "lucide-react"; +import * as React from "react"; + +import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay"; +import { + BACKUP_AVAILABILITY_MS, + useEncryptedBackup, +} from "@/features/settings/EncryptedBackupProvider"; +import { + BackupTestFlow, + initialBackupTestProgress, +} from "@/features/settings/ui/BackupTestFlow"; +import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator"; +import { getNsec } from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +function BackupAvailabilityFill({ + availableUntil, +}: { + availableUntil: number; +}) { + const [{ durationMs, initialWidth }] = React.useState(() => { + const remainingMs = Math.max(0, availableUntil - Date.now()); + return { + durationMs: remainingMs, + initialWidth: Math.min(100, (remainingMs / BACKUP_AVAILABILITY_MS) * 100), + }; + }); + const [width, setWidth] = React.useState(initialWidth); + + React.useEffect(() => { + const frame = window.requestAnimationFrame(() => setWidth(0)); + return () => window.cancelAnimationFrame(frame); + }, []); + + return ( +
diff --git a/desktop/src/features/settings/ui/harnessCatalogCopy.ts b/desktop/src/features/settings/ui/harnessCatalogCopy.ts index 79c55f014..70439091b 100644 --- a/desktop/src/features/settings/ui/harnessCatalogCopy.ts +++ b/desktop/src/features/settings/ui/harnessCatalogCopy.ts @@ -36,7 +36,7 @@ const HARNESS_DESCRIPTIONS: Record = { // https://moonshotai.github.io/kimi-cli/en/ kimi: "A terminal coding agent for software development and command-line tasks.", // Sources: https://ampcode.com, https://ampcode.com/manual - amp: "A coding agent from Sourcegraph.", + amp: "A coding agent for your terminal and editor.", // Sources: https://github.com/NousResearch/hermes-agent, // https://hermes-agent.nousresearch.com/docs/ hermes: "A general-purpose AI agent from Nous Research.", diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 84ee10b68..8274034ed 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -49,7 +49,9 @@ import { isWebSocketClose, shouldRefuseConnect, shouldScheduleReconnect, + shouldWaitForScheduledReconnect, } from "@/shared/api/relayReconnectPolicy"; +import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters"; import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; @@ -57,26 +59,12 @@ const RECONNECT_BASE_DELAY_MS = 1_000, RECONNECT_MAX_DELAY_MS = 30_000, EVENT_BATCH_MS = 16; -/** - * Op-level timeout constants. Raised from 8 s to 25 s to survive degraded - * networks where TLS handshakes and DNS resolution can take 3–10 s. - */ export const AUTH_TIMEOUT_MS = 25_000; export const HISTORY_TIMEOUT_MS = 25_000; export const PUBLISH_TIMEOUT_MS = 25_000; -/** - * The connection must remain stable for this long after a successful AUTH - * before the reconnect backoff delay resets to its base value. Stability- - * gated reset prevents repeated fast reconnects (flapping) from erasing the - * backoff that throttles them. - */ export const BACKOFF_RESET_STABLE_MS = 60_000; -/** - * Passive liveness check. The relay sends heartbeat pings every 30s; if no - * inbound frame arrives for two heartbeat windows, treat the socket as stalled. - */ const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_IDLE_TIMEOUT_MS = 60_000; @@ -85,6 +73,7 @@ export class RelayClient { private relayUrl: string | null = null; private connectPromise: Promise | null = null; private reconnectTimeout: number | null = null; + private reconnectWaiters = new RelayReconnectWaiters(); private reconnectDelayMs = RECONNECT_BASE_DELAY_MS; private keepAliveRequested = false; private authRequest: { @@ -105,16 +94,6 @@ export class RelayClient { private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; - /** - * Sticky terminal flag. Set when `resetConnection` is called with - * `reconnect: false` (today: auth rejection). Acts as a hard guard against - * the reconnect-timer / retry-wrapper paths racing back to "reconnecting" - * after we've already declared the session dead. - * - * Cleared only on explicit user re-engagement: `disconnect()` (community - * switch — the singleton is being reused for a different community) and - * `preconnect()` (caller is asking us to come back up). - */ private terminal = false; private connectionStateEmitter = new RelayConnectionStateEmitter("idle"); @@ -127,21 +106,10 @@ export class RelayClient { }, }); - /** - * Track which channel the user is currently viewing so its subscriptions - * are sent first during reconnect replay — reducing visible latency on - * degraded networks where the relay REQ storm would otherwise delay all - * channels equally. - */ setVisibleChannelId(id: string | null) { this.visibleChannelId = id; } - /** - * Cleanly tear down the connection without scheduling a reconnect. - * Used during community switches to reset the singleton before the - * new community applies. - */ disconnect() { const error = new Error("Relay disconnected for community switch."); @@ -169,6 +137,7 @@ export class RelayClient { } this.connectPromise = null; + this.reconnectWaiters.settle(error); if (this.authRequest) { window.clearTimeout(this.authRequest.timeout); @@ -247,10 +216,6 @@ export class RelayClient { return this.fetchHistory(filter); } - /** - * Return the first event matching `filter` as soon as it arrives, without - * waiting for EOSE. Resolves to `null` when EOSE arrives before any event. - */ async fetchFirstEvent( filter: RelaySubscriptionFilter, ): Promise { @@ -462,10 +427,24 @@ export class RelayClient { async preconnect() { // Explicit re-engagement. If the session went terminal (auth rejection) - // the caller is asking us to try again, so clear the latch. + // the caller is asking us to try again, so clear the latch. A manual + // reconnect also bypasses the current delay once; ordinary operations do + // not, so background traffic cannot continuously defeat backoff. this.terminal = false; this.keepAliveRequested = true; - await this.ensureConnected(); + if (this.reconnectTimeout !== null) { + window.clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + try { + await this.ensureConnected(); + this.reconnectWaiters.settle(); + } catch (error) { + this.reconnectWaiters.settle( + this.normalizeRelayError(error, "Relay reconnect failed."), + ); + throw error; + } } subscribeToReconnects(listener: () => void) { @@ -508,9 +487,15 @@ export class RelayClient { return; } - if (this.reconnectTimeout) { - window.clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; + if ( + shouldWaitForScheduledReconnect({ + hasPendingReconnect: this.reconnectTimeout !== null, + }) + ) { + // The reconnect coordinator owns outage pacing. Query, publish, and + // subscription callers must wait for its scheduled attempt instead of + // clearing the timer and creating an immediate reconnect storm. + return this.waitForScheduledReconnect(); } const connectPromise = this.connect(); @@ -964,6 +949,13 @@ export class RelayClient { } } + private waitForScheduledReconnect(): Promise { + if (this.reconnectTimeout === null) { + return this.ensureConnected(); + } + return this.reconnectWaiters.wait(); + } + private scheduleReconnect() { if ( !shouldScheduleReconnect({ @@ -989,9 +981,14 @@ export class RelayClient { this.reconnectTimeout = window.setTimeout(() => { this.reconnectTimeout = null; - void this.ensureConnected().catch(() => { - this.scheduleReconnect(); - }); + void this.ensureConnected() + .then(() => this.reconnectWaiters.settle()) + .catch((error) => { + this.reconnectWaiters.settle( + this.normalizeRelayError(error, "Relay reconnect failed."), + ); + this.scheduleReconnect(); + }); }, delay); } @@ -1049,6 +1046,9 @@ export class RelayClient { window.clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } + if (options?.reconnect === false) { + this.reconnectWaiters.settle(error); + } if (this.wsId !== null) { void closeWebSocket(this.wsId, "connection reset"); diff --git a/desktop/src/shared/api/relayReconnectPolicy.test.mjs b/desktop/src/shared/api/relayReconnectPolicy.test.mjs index 6f375fb43..e6856ede1 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.test.mjs +++ b/desktop/src/shared/api/relayReconnectPolicy.test.mjs @@ -6,6 +6,7 @@ import { isWebSocketClose, shouldRefuseConnect, shouldScheduleReconnect, + shouldWaitForScheduledReconnect, } from "./relayReconnectPolicy.ts"; // The "happy" baseline that *should* schedule a reconnect: not terminal, @@ -79,6 +80,17 @@ test("keep-alive alone is enough to schedule", () => { ); }); +test("ordinary operations wait for a scheduled reconnect instead of bypassing backoff", () => { + assert.equal( + shouldWaitForScheduledReconnect({ hasPendingReconnect: true }), + true, + ); + assert.equal( + shouldWaitForScheduledReconnect({ hasPendingReconnect: false }), + false, + ); +}); + test("shouldRefuseConnect mirrors terminal", () => { assert.equal(shouldRefuseConnect({ terminal: false }), false); assert.equal(shouldRefuseConnect({ terminal: true }), true); diff --git a/desktop/src/shared/api/relayReconnectPolicy.ts b/desktop/src/shared/api/relayReconnectPolicy.ts index a2cf35821..00d8e412b 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.ts +++ b/desktop/src/shared/api/relayReconnectPolicy.ts @@ -37,6 +37,12 @@ export function shouldScheduleReconnect(inputs: RelayReconnectInputs): boolean { return true; } +export function shouldWaitForScheduledReconnect(inputs: { + hasPendingReconnect: boolean; +}): boolean { + return inputs.hasPendingReconnect; +} + /** Whether `ensureConnected()` should refuse with a terminal error. */ export function shouldRefuseConnect(inputs: { terminal: boolean }): boolean { return inputs.terminal; diff --git a/desktop/src/shared/api/relayReconnectWaiters.test.mjs b/desktop/src/shared/api/relayReconnectWaiters.test.mjs new file mode 100644 index 000000000..ddf324452 --- /dev/null +++ b/desktop/src/shared/api/relayReconnectWaiters.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { RelayReconnectWaiters } from "./relayReconnectWaiters.ts"; + +test("settle releases every operation waiting on a successful reconnect", async () => { + const waiters = new RelayReconnectWaiters(); + const first = waiters.wait(); + const second = waiters.wait(); + + waiters.settle(); + + await Promise.all([first, second]); +}); + +test("settle rejects every operation after a failed reconnect", async () => { + const waiters = new RelayReconnectWaiters(); + const first = waiters.wait(); + const second = waiters.wait(); + const error = new Error("relay unavailable"); + + waiters.settle(error); + + await assert.rejects(first, error); + await assert.rejects(second, error); +}); diff --git a/desktop/src/shared/api/relayReconnectWaiters.ts b/desktop/src/shared/api/relayReconnectWaiters.ts new file mode 100644 index 000000000..06ad1daa3 --- /dev/null +++ b/desktop/src/shared/api/relayReconnectWaiters.ts @@ -0,0 +1,21 @@ +export class RelayReconnectWaiters { + private waiters = new Set<{ + resolve: () => void; + reject: (error: Error) => void; + }>(); + + wait(): Promise { + return new Promise((resolve, reject) => { + this.waiters.add({ resolve, reject }); + }); + } + + settle(error?: Error) { + const waiters = [...this.waiters]; + this.waiters.clear(); + for (const waiter of waiters) { + if (error) waiter.reject(error); + else waiter.resolve(); + } + } +} diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index e6056a4a5..e6ec266bf 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -49,3 +49,52 @@ export async function persistCurrentIdentity(): Promise { export async function signOut(): Promise { await invokeTauri("sign_out"); } + +export type GeneratePassphraseOptions = { + /** Word count; Rust clamps to its allowed range (currently 3–10). */ + words?: number; + /** Separator joined between words. Defaults to a space in Rust. */ + separator?: string; +}; + +/** Generate a word passphrase (EFF short wordlist, OS entropy) in Rust. */ +export async function generateBackupPassphrase( + options?: GeneratePassphraseOptions, +): Promise { + return invokeTauri("generate_backup_passphrase", { + words: options?.words, + separator: options?.separator, + }); +} + +/** Encrypt the current identity as an in-memory NIP-49 backup for native save. */ +export async function createNcryptsecBackup(password: string): Promise { + return invokeTauri("create_ncryptsec_backup", { password }); +} + +/** Save a portable backup copy. Returns null when the native dialog is cancelled. */ +export async function saveNcryptsecCopy( + ncryptsec: string, +): Promise { + return ( + (await invokeTauri("save_ncryptsec_copy", { ncryptsec })) ?? + null + ); +} + +export type BackupVerification = { + pubkey: string; + npub: string; + matchesCurrentIdentity: boolean; +}; + +/** Decrypt locally and return only the backup's public identity and match state. */ +export async function verifyNcryptsecBackup( + ncryptsec: string, + password: string, +): Promise { + return invokeTauri("verify_ncryptsec_backup", { + ncryptsec, + password, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 96948c31d..07eaa7790 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; -import { decode } from "nostr-tools/nip19"; +import { decode, npubEncode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; import { @@ -277,6 +277,8 @@ type E2eConfig = { channelWindowDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; + /** Override whether get_profile reports a real kind:0 event. */ + profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; searchProfiles?: MockSearchProfileSeed[]; @@ -415,6 +417,14 @@ type E2eConfig = { * autosave behaviour while a request is in flight. 0/undefined = instant. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message instead of returning the * mock nsec string. Use `nsecErrors` for sequenced failure/success. @@ -1098,6 +1108,11 @@ declare global { __BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void; __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; __BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number; + __BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__?: ( + unavailable: boolean, + ) => void; + __BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[]; + __BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void; __BUZZ_E2E_SET_MESH__?: (mesh: { admitted?: boolean; models?: Array<{ id: string; name: string | null }>; @@ -2810,6 +2825,8 @@ const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); +let mockWebsocketUnavailable = false; +const relayWebsocketConnectAttemptStarts: number[] = []; let mockWebsocketSendMutexWedged = false; let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); @@ -2890,9 +2907,7 @@ const mockMeshState: { servingUsage: MockServingUsage; } = { admitted: true, - models: [ - { id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" }, - ], + models: [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }], denyReason: "not a relay member", nodeState: "off", nodeMode: null, @@ -2901,9 +2916,7 @@ const mockMeshState: { function resetMockMesh() { mockMeshState.admitted = true; - mockMeshState.models = [ - { id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" }, - ]; + mockMeshState.models = [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }]; mockMeshState.denyReason = "not a relay member"; mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; @@ -5382,6 +5395,13 @@ async function handleGetChannels(config: E2eConfig | undefined) { async function handleGetProfile(config: E2eConfig | undefined) { const identity = getIdentity(config); + const forcedHasProfileEvent = config?.mock?.profileHasEvent; + if (forcedHasProfileEvent !== undefined) { + return { + ...cloneProfile(ensureMockProfile(config)), + has_profile_event: forcedHasProfileEvent, + }; + } if (!identity) { const profileReadDelayMs = config?.mock?.profileReadDelayMs ?? 0; if (profileReadDelayMs > 0) { @@ -7199,6 +7219,8 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +let backupVerificationCallCount = 0; +let backupSaveCallCount = 0; // Per-page explicit catalog publication outcomes. let personaSharePublicationCallCount = 0; @@ -8917,6 +8939,7 @@ async function resolveGetEvent( } async function connectRealSocket(args: { url?: string; onMessage: unknown }) { + relayWebsocketConnectAttemptStarts.push(Date.now()); const wsId = nextSocketId++; const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL); const handler = resolveHandler(args.onMessage); @@ -8945,6 +8968,10 @@ async function connectRealSocket(args: { url?: string; onMessage: unknown }) { } async function connectMockSocket(args: { onMessage: unknown }) { + relayWebsocketConnectAttemptStarts.push(Date.now()); + if (mockWebsocketUnavailable) { + throw new Error("mock relay unavailable"); + } const connectError = getConfig()?.mock?.websocketConnectErrors?.shift(); if (connectError) { throw new Error(connectError); @@ -9390,6 +9417,8 @@ export function maybeInstallE2eTauriMocks() { } mockClosedChannelLiveSubscription = false; + mockWebsocketUnavailable = false; + relayWebsocketConnectAttemptStarts.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } : null; @@ -9613,6 +9642,16 @@ export function maybeInstallE2eTauriMocks() { } return sockets.length; }; + window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__ = (unavailable) => { + mockWebsocketUnavailable = unavailable; + if (unavailable) relayWebsocketConnectAttemptStarts.length = 0; + }; + window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [ + ...relayWebsocketConnectAttemptStarts, + ]; + window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => { + relayWebsocketConnectAttemptStarts.length = 0; + }; // Tests vary mesh admission and models to exercise provider discovery and // the managed-agent start preflight. window.__BUZZ_E2E_SET_MESH__ = (mesh) => { @@ -9744,6 +9783,25 @@ export function maybeInstallE2eTauriMocks() { } case "mesh_installed_models": return mockMeshState.models; + case "mesh_model_catalog": + return { + gpuName: "Mock Apple GPU", + vramDisplay: "32 GB", + vramGb: 32, + recommended: "Gemma-4-E4B-it-Q4_K_M", + entries: [ + { + name: "Gemma-4-E4B-it-Q4_K_M", + size: "3.5GB", + sizeGb: 3.5, + description: "Buzz-curated local agent model", + fit: "comfortable", + installed: true, + recommended: true, + curated: true, + }, + ], + }; case "mesh_node_status": return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode); case "mesh_serving_usage": @@ -9819,6 +9877,43 @@ export function maybeInstallE2eTauriMocks() { // harness there is nothing to wipe; resolving is enough — specs // assert invocation via __BUZZ_E2E_COMMANDS__ and the pending UI. return; + case "generate_backup_passphrase": + return "correct horse battery staple"; + case "create_ncryptsec_backup": { + const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return "ncryptsec1mockbackupmaterial"; + } + case "save_ncryptsec_copy": { + const paths = activeConfig?.mock?.backupSavePaths ?? [ + "/tmp/buzz-identity.ncryptsec", + ]; + const index = Math.min(backupSaveCallCount, paths.length - 1); + backupSaveCallCount += 1; + return paths[index]; + } + case "verify_ncryptsec_backup": { + const errors = activeConfig?.mock?.backupVerificationErrors ?? [null]; + const index = Math.min(backupVerificationCallCount, errors.length - 1); + const error = errors[index]; + if (error) { + backupVerificationCallCount += 1; + throw new Error(error); + } + const pubkeys = activeConfig?.mock?.backupVerificationPubkeys ?? [ + identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, + ]; + const pubkey = pubkeys[Math.min(index, pubkeys.length - 1)]; + backupVerificationCallCount += 1; + return { + pubkey, + npub: npubEncode(pubkey), + matchesCurrentIdentity: + pubkey === (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey), + }; + } case "get_nsec": { const nsecSequence = activeConfig?.mock?.nsecErrors; if (nsecSequence && nsecSequence.length > 0) { diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 13eda788b..3cbe097c0 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -93,10 +93,10 @@ async function sharePersonaToCatalog( ) { await page.getByLabel(`Open actions for ${displayName}`).click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await page.getByTestId("persona-share-catalog-access").click(); - await page - .getByRole("menuitemradio", { name: "Shared", exact: true }) - .click(); + const catalogToggle = page.getByTestId("persona-share-catalog-access"); + await expect(catalogToggle).not.toBeChecked(); + await catalogToggle.click(); + await expect(catalogToggle).toBeChecked(); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -416,7 +416,7 @@ test("the new agent card offers create, discover, and import", async ({ const cardBoxes = await agentCards.evaluateAll((cards) => cards.map((card) => { const box = card.getBoundingClientRect(); - return { right: box.right, top: box.top }; + return { left: box.left, right: box.right, top: box.top }; }), ); const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top)); @@ -425,12 +425,16 @@ test("the new agent card offers create, discover, and import", async ({ .filter(({ top }) => Math.abs(top - firstRowTop) < 1) .map(({ right }) => right), ); + const leftmostFirstRowCard = Math.min( + ...cardBoxes + .filter(({ top }) => Math.abs(top - firstRowTop) < 1) + .map(({ left }) => left), + ); expect(headerBox).not.toBeNull(); - expect( - Math.abs( - (headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard, - ), - ).toBeLessThan(1); + expect(Math.abs((headerBox?.x ?? 0) - leftmostFirstRowCard)).toBeLessThan(1); + expect(rightmostFirstRowCard).toBeLessThanOrEqual( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0) + 1, + ); await newAgentCard.click(); await expect( @@ -492,6 +496,63 @@ test("the new team card offers create and import", async ({ page }) => { ).toBeVisible(); }); +test("team cards follow the agents grid alignment at compact widths", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:team-layout", + displayName: "Team layout agent", + systemPrompt: "A test agent for team layout alignment.", + }, + ], + teams: [ + { + id: "team-layout", + name: "Team layout", + personaIds: ["custom:team-layout"], + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const agentsContent = page.getByTestId("agents-page-content"); + const firstAgentCard = page.getByTestId( + "persona-agent-row-custom:team-layout", + ); + const firstTeamCard = page.getByTestId("team-card-team-layout"); + const agentGrid = firstAgentCard.locator("xpath=.."); + const teamGrid = firstTeamCard.locator("xpath=.."); + const firstAgentGridCard = agentGrid.locator(":scope > *").first(); + const firstTeamGridCard = teamGrid.locator(":scope > *").first(); + + await agentsContent.evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + const wideAgentBox = await firstAgentGridCard.boundingBox(); + const wideTeamBox = await firstTeamGridCard.boundingBox(); + expect(wideAgentBox).not.toBeNull(); + expect(wideTeamBox).not.toBeNull(); + expect(Math.abs((wideAgentBox?.x ?? 0) - (wideTeamBox?.x ?? 0))).toBeLessThan( + 1, + ); + + await agentsContent.evaluate((element) => { + (element as HTMLElement).style.width = "600px"; + }); + await expect + .poll(async () => (await firstAgentGridCard.boundingBox())?.x ?? 0) + .toBeGreaterThan(wideAgentBox?.x ?? 0); + + const compactAgentBox = await firstAgentGridCard.boundingBox(); + const compactTeamBox = await firstTeamGridCard.boundingBox(); + expect( + Math.abs((compactAgentBox?.x ?? 0) - (compactTeamBox?.x ?? 0)), + ).toBeLessThan(1); +}); + test("team cards use the thread-style overlapping avatar stack", async ({ page, }) => { @@ -634,6 +695,62 @@ test("unconfigured agent defaults use the setup label", async ({ page }) => { ); }); +test("moves agent actions into an overflow menu in a narrow view", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:compact-actions", + displayName: "Compact actions agent", + isActive: true, + systemPrompt: "A test agent for compact header actions.", + }, + ], + managedAgents: [ + { + name: "Compact actions instance", + personaId: "custom:compact-actions", + pubkey: "cd".repeat(32), + status: "running", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + + await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + await expect( + page.getByText("Set up and manage your agents.", { exact: true }), + ).toHaveJSProperty("scrollHeight", 24); + + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "600px"; + }); + await expect(page.getByTestId("agent-defaults-button")).toBeHidden(); + await page.getByTestId("agent-actions-menu-trigger").click(); + await expect( + page.getByRole("menuitem", { name: "Set agent defaults" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Stop running agents" }), + ).toBeVisible(); + + await page.getByRole("menuitem", { name: "Set agent defaults" }).click(); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toBeVisible(); + + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toHaveCount(0); + await expect(page.getByTestId("agent-defaults-button")).toBeFocused(); +}); + test("agent catalog chooser order stays stable when selection changes", async ({ page, }) => { @@ -862,21 +979,27 @@ test("custom personas share with people and keep export separate", async ({ page.getByRole("heading", { name: "Share Animation Auditor" }), ).toBeVisible(); await expect(shareDialog.getByText("Added by You")).toHaveCount(0); - const sendDescription = shareDialog.getByTestId( - "persona-share-send-description", + const shareDescription = shareDialog.getByTestId( + "persona-share-share-description", ); - await expect(sendDescription).toHaveText( - "They’ll receive a copy they can add and use. Changes you make later won’t sync.", + await expect(shareDescription).toHaveText( + "Anyone you share this agent with will receive a copy they can add and use. Changes you make later won’t sync.", ); - await expect(sendDescription).toHaveClass( - /text-xs.*text-secondary-foreground\/75/, + await expect(shareDescription).toHaveClass(/text-sm.*text-muted-foreground/); + const shareDescriptionId = await shareDescription.getAttribute("id"); + expect(shareDescriptionId).toBeTruthy(); + await expect(shareDialog).toHaveAttribute( + "aria-describedby", + shareDescriptionId ?? "", ); - const sendDescriptionMetrics = await sendDescription.evaluate((element) => ({ - height: element.getBoundingClientRect().height, - lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), - })); - expect(sendDescriptionMetrics.height).toBeLessThanOrEqual( - sendDescriptionMetrics.lineHeight + 1, + const shareDescriptionMetrics = await shareDescription.evaluate( + (element) => ({ + height: element.getBoundingClientRect().height, + lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), + }), + ); + expect(shareDescriptionMetrics.height).toBeLessThanOrEqual( + shareDescriptionMetrics.lineHeight * 2 + 1, ); await expect( shareDialog.getByRole("heading", { name: "Who has access" }), @@ -884,57 +1007,49 @@ test("custom personas share with people and keep export separate", async ({ await expect(shareDialog.getByText("Owner", { exact: true })).toHaveCount(0); await expect(shareDialog.getByText("(You)", { exact: true })).toHaveCount(0); const linkRow = page.getByTestId("persona-share-link-row"); + await expect(page.getByTestId("persona-share-copy-link")).toBeVisible(); await expect( - linkRow.getByRole("heading", { name: "Share with a link" }), - ).toBeVisible(); + shareDialog.getByRole("heading", { name: "Share with a link" }), + ).toHaveCount(0); await expect( - linkRow.getByText("Anyone with the link can add and use a copy."), - ).toHaveClass(/text-xs.*text-secondary-foreground\/75/); + shareDialog.getByText("Anyone with the link can add and use a copy."), + ).toHaveCount(0); await expect(page.getByTestId("persona-share-send")).toHaveCount(0); const copyLinkButton = page.getByTestId("persona-share-copy-link"); - const linkIcon = page.getByTestId("persona-share-link-icon"); - const linkCopy = page.getByTestId("persona-share-link-copy"); const catalogSection = page.getByTestId("persona-share-catalog"); - const staticShareLevel = page.getByTestId("persona-share-share-level"); - const shareLevelRow = page.getByTestId("persona-share-share-level-row"); + const shareMainCard = page.getByTestId("persona-share-main-card"); + const exportAgentRow = page.getByTestId("persona-share-export"); await waitForAnimations(page); const [ linkRowBox, initialCopyLinkButtonBox, - linkIconBox, - linkCopyBox, catalogSectionBox, - staticShareLevelBox, - shareLevelRowBox, + shareMainCardBox, + exportAgentRowBox, ] = await Promise.all([ linkRow.boundingBox(), copyLinkButton.boundingBox(), - linkIcon.boundingBox(), - linkCopy.boundingBox(), catalogSection.boundingBox(), - staticShareLevel.boundingBox(), - shareLevelRow.boundingBox(), + shareMainCard.boundingBox(), + exportAgentRow.boundingBox(), ]); - const sendDescriptionBox = await sendDescription.boundingBox(); + const shareDescriptionBox = await shareDescription.boundingBox(); const recipientFieldBox = await page .getByTestId("persona-share-recipient-field") .boundingBox(); - // Reading order: who → how it goes out → what's included → catalog. - expect(sendDescriptionBox?.y ?? 0).toBeGreaterThanOrEqual( - (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), + // Without memories, the recipient field flows directly into the link action. + expect(recipientFieldBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareDescriptionBox?.y ?? 0) + (shareDescriptionBox?.height ?? 0), + ); + await expect(page.getByTestId("persona-share-link-settings")).toHaveCount(0); + await expect(page.getByTestId("persona-share-share-level-row")).toHaveCount( + 0, ); expect(linkRowBox?.y ?? 0).toBeGreaterThanOrEqual( - (sendDescriptionBox?.y ?? 0) + (sendDescriptionBox?.height ?? 0), + (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), ); - expect(shareLevelRowBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), - ); - expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( - (shareLevelRowBox?.y ?? 0) + (shareLevelRowBox?.height ?? 0), - ); - // Copy link is the link row's own action, not a stranded footer button, so - // it rides on that row, vertically centred with the link icon and flush to - // the row's right edge. + // Copy link stays inside the main card, after the shared settings divider, while catalog and + // export are separate rows below it. expect(initialCopyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( linkRowBox?.y ?? 0, ); @@ -942,40 +1057,15 @@ test("custom personas share with people and keep export separate", async ({ (initialCopyLinkButtonBox?.y ?? 0) + (initialCopyLinkButtonBox?.height ?? 0), ).toBeLessThanOrEqual((linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 1); - expect( - Math.abs( - (initialCopyLinkButtonBox?.y ?? 0) + - (initialCopyLinkButtonBox?.height ?? 0) / 2 - - ((linkIconBox?.y ?? 0) + (linkIconBox?.height ?? 0) / 2), - ), - ).toBeLessThanOrEqual(1); - expect( - Math.abs( - (linkRowBox?.x ?? 0) + - (linkRowBox?.width ?? 0) - - ((initialCopyLinkButtonBox?.x ?? 0) + - (initialCopyLinkButtonBox?.width ?? 0)), - ), - ).toBeLessThanOrEqual(1); - expect( - Math.abs( - (linkCopyBox?.y ?? 0) + - (linkCopyBox?.height ?? 0) / 2 - - ((linkIconBox?.y ?? 0) + (linkIconBox?.height ?? 0) / 2), - ), - ).toBeLessThanOrEqual(1); - await expect(page.getByTestId("persona-share-link-divider")).toHaveCount(0); - await expect(page.getByTestId("persona-share-copy-link-footer")).toHaveCount( - 0, + await expect(page.getByTestId("persona-share-link-divider")).toHaveClass( + /bg-input\/40/, + ); + expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0) + 12, + ); + expect(exportAgentRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0) + 12, ); - expect( - Math.abs( - (shareLevelRowBox?.y ?? 0) + - (shareLevelRowBox?.height ?? 0) / 2 - - ((staticShareLevelBox?.y ?? 0) + - (staticShareLevelBox?.height ?? 0) / 2), - ), - ).toBeLessThanOrEqual(1); await expect(copyLinkButton).toHaveClass( /border.*bg-background.*border-border/, ); @@ -992,12 +1082,7 @@ test("custom personas share with people and keep export separate", async ({ await expect.poll(copyLinkHasVisibleShadow).toBe(false); await copyLinkButton.hover(); await expect.poll(copyLinkHasVisibleShadow).toBe(false); - await expect(page.getByTestId("persona-share-share-level")).toHaveText( - "No memories included", - ); - await expect( - shareDialog.getByText("No memories included", { exact: true }), - ).toHaveCount(1); + await expect(page.getByTestId("persona-share-share-level")).toHaveCount(0); await expect(page.getByTestId("persona-share-recipient-access")).toHaveCount( 0, ); @@ -1014,20 +1099,10 @@ test("custom personas share with people and keep export separate", async ({ await expect( shareDialog.getByText("File format", { exact: true }), ).toHaveCount(0); - const shareMainCard = page.getByTestId("persona-share-main-card"); - const exportAgentRow = page.getByTestId("persona-share-export"); await expect(exportAgentRow).toHaveText("Export agent"); await expect(shareMainCard.getByTestId("persona-share-export")).toHaveCount( 0, ); - await waitForAnimations(page); - const shareMainCardBox = await shareMainCard.boundingBox(); - const exportAgentRowBox = await exportAgentRow.boundingBox(); - const shareCardGap = - (exportAgentRowBox?.y ?? 0) - - ((shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0)); - expect(shareCardGap).toBeGreaterThanOrEqual(12); - expect(shareCardGap).toBeLessThan(16); const [shareMainCardStyles, exportAgentRowStyles] = await Promise.all([ shareMainCard.evaluate((element) => ({ borderRadius: getComputedStyle(element).borderRadius, @@ -1411,9 +1486,9 @@ This deliberately long fenced-code example must not establish the minimum width const shareMainCard = shareDialog.getByTestId("persona-share-main-card"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); const catalogSection = shareDialog.getByTestId("persona-share-catalog"); - await expect( - shareMainCard.getByTestId("persona-share-catalog"), - ).toBeVisible(); + await expect(shareMainCard.getByTestId("persona-share-catalog")).toHaveCount( + 0, + ); await expect(catalogSection).toContainText("Share to catalog"); await expect(catalogSection).toContainText( "Anyone in this community can find and use a copy.", @@ -1427,26 +1502,18 @@ This deliberately long fenced-code example must not establish the minimum width catalogSection.boundingBox(), shareMainCard.boundingBox(), ]); - // Copy link belongs to the link row above, so the catalog is the section - // that closes the card rather than trailing an orphaned button. + // Catalog is its own card below the sharing controls. expect( (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), - ).toBeLessThanOrEqual(catalogSectionBox?.y ?? 0); - expect( - (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), ).toBeLessThanOrEqual( (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), ); - await expect(catalogAccess).toHaveText("Not shared"); + expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), + ); + await expect(catalogAccess).not.toBeChecked(); await catalogAccess.click(); - await expect(page.getByRole("menuitemradio")).toHaveText([ - "Not shared", - "Shared", - ]); - await page - .getByRole("menuitemradio", { name: "Shared", exact: true }) - .click(); - await expect(catalogAccess).toHaveText("Shared"); + await expect(catalogAccess).toBeChecked(); const storedPersonas = await invokeTauri< Array<{ id: string; shared: boolean }> >(page, "list_personas"); @@ -1534,11 +1601,9 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogAccess).toHaveText("Shared"); + await expect(catalogAccess).toBeChecked(); await catalogAccess.click(); - await page - .getByRole("menuitemradio", { name: "Not shared", exact: true }) - .click(); + await expect(catalogAccess).not.toBeChecked(); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -1570,9 +1635,6 @@ test("a queued catalog share is not presented as relay-published", async ({ await page.getByLabel("Open actions for Queued Catalog Agent").click(); await page.getByRole("menuitem", { name: "Share" }).click(); await page.getByTestId("persona-share-catalog-access").click(); - await page - .getByRole("menuitemradio", { name: "Shared", exact: true }) - .click(); await expect( page.getByText( @@ -1674,8 +1736,10 @@ test("a community member can discover and add another member's catalog agent", a ); await expect(remoteEntry).toContainText("Alice’s Reviewer"); await remoteEntry.click(); + // The detail pane resolves the publisher's display name; 'Community member' + // is only the fallback for an unresolvable pubkey. await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Added by Community member", + "Added by alice", ); await page @@ -1728,6 +1792,38 @@ test("a community member can discover and add another member's catalog agent", a expect(await countCommandInvocations(page, "create_persona")).toBe(1); }); +test("catalog detail shows Community member when the publisher profile cannot be resolved", async ({ + page, +}) => { + // A pubkey that is not in the mock profile registry — profile resolution + // will fail and the detail pane must fall back gracefully. + const unknownPubkey = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const personaId = "unresolvable-reviewer"; + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: unknownPubkey, + sourcePersonaId: personaId, + displayName: "Mystery Agent", + systemPrompt: "Published by someone whose profile cannot be fetched.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + await page + .getByTestId( + `persona-catalog-list-item-catalog:${unknownPubkey}:${personaId}`, + ) + .click(); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Added by Community member", + ); +}); + test("one share level selector drives both the link and send paths", async ({ page, }) => { @@ -1801,7 +1897,7 @@ test("one share level selector drives both the link and send paths", async ({ const shareLevel = shareDialog.getByLabel("What to include", { exact: true, }); - const catalogAccess = shareDialog.getByLabel("What to share in the catalog"); + const catalogAccess = shareDialog.getByLabel("Share to catalog"); const recipientField = page.getByTestId("persona-share-recipient-field"); const emptyRecipientFieldBox = await recipientField.boundingBox(); await expect(shareDialog.getByTestId("persona-share-send")).toHaveCount(0); @@ -1813,13 +1909,14 @@ test("one share level selector drives both the link and send paths", async ({ await expect(shareLevel).toHaveCSS("text-decoration-line", "none"); await expect(shareLevel).toHaveCSS("padding-left", "8px"); await expect(shareLevel).toHaveCSS("padding-right", "8px"); - await expect(catalogAccess).toHaveText("Not shared"); - await catalogAccess.click(); - await expect(page.getByRole("menuitemradio")).toHaveText([ - "Not shared", - "Shared", - ]); - await page.keyboard.press("Escape"); + await expect(catalogAccess).not.toBeChecked(); + await expect(catalogAccess).toHaveCSS("cursor", "default"); + await expect( + shareDialog.getByTestId("persona-share-link-settings"), + ).toContainText("Share settings"); + await expect( + shareDialog.getByText("Share settings", { exact: true }), + ).toHaveClass(/text-xs.*text-secondary-foreground\/75/); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); const recipientFieldBox = await recipientField.boundingBox(); const [shareLevelBox, copyLinkButtonBox, catalogAccessBox] = @@ -1828,15 +1925,15 @@ test("one share level selector drives both the link and send paths", async ({ copyLinkButton.boundingBox(), catalogAccess.boundingBox(), ]); - // Reading order: who → how it goes out → what's included → catalog. - expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( + // Reading order: who → what's included → copy link → catalog. + expect(shareLevelBox?.y ?? 0).toBeGreaterThanOrEqual( (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), ); - expect(shareLevelBox?.y ?? 0).toBeGreaterThanOrEqual( - (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), + expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareLevelBox?.y ?? 0) + (shareLevelBox?.height ?? 0), ); expect(catalogAccessBox?.y ?? 0).toBeGreaterThanOrEqual( - (shareLevelBox?.y ?? 0) + (shareLevelBox?.height ?? 0), + (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), ); // The memory choice is stated once, governing both delivery actions — // neither the recipients row nor the link row carries its own copy. diff --git a/desktop/tests/e2e/helpers/twoRelayHarness.ts b/desktop/tests/e2e/helpers/twoRelayHarness.ts index 98acd8639..43eb45841 100644 --- a/desktop/tests/e2e/helpers/twoRelayHarness.ts +++ b/desktop/tests/e2e/helpers/twoRelayHarness.ts @@ -162,6 +162,7 @@ export class TwoRelayHarness { BUZZ_METRICS_PORT: String(relay.ports.metrics), BUZZ_REQUIRE_AUTH_TOKEN: "false", BUZZ_RECONCILE_CHANNELS: "true", + BUZZ_AUTO_MIGRATE: "true", }); await this.waitForHealth(relay, child); } diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index 7c360368a..b2e8fc28a 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -15,7 +15,7 @@ type E2eWindow = Window & { }) => void; }; -test("Share compute has a clear empty state and starts and stops sharing", async ({ +test("Share compute selects the curated default and starts and stops sharing", async ({ page, }) => { await installMockBridge(page); @@ -30,22 +30,32 @@ test("Share compute has a clear empty state and starts and stops sharing", async await expect(card).toContainText( "Choose a suggested model below, or enter a model reference or local file", ); - await expect(toggle).toBeDisabled(); - - await model.fill("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M"); + await expect(model).toHaveValue("Gemma-4-E4B-it-Q4_K_M"); + await expect(toggle).toBeEnabled(); await expect(card).toContainText( "Buzz downloads remote models when sharing starts", ); - await expect(toggle).toBeEnabled(); await toggle.click(); await expect(toggle).toBeChecked(); - await expect(card).toContainText("Sharing SmolLM2 135M with relay members"); + await expect(card).toContainText("Sharing Gemma 4 E4B with relay members"); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), ) .toContain("mesh_start_node"); + await expect + .poll(() => + page.evaluate( + () => (window as E2eWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ), + ) + .toContainEqual({ + command: "mesh_start_node", + payload: { + request: { mode: "serve", modelId: "Gemma-4-E4B-it-Q4_K_M" }, + }, + }); await toggle.click(); await expect(toggle).not.toBeChecked(); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 2487dbbf9..43a617ffd 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -734,7 +734,9 @@ test("opens a single-level thread panel with inline expansion", async ({ return body.scrollHeight - body.clientHeight; }); }) - .toBeGreaterThanOrEqual(160); + // Compact continuation rows intentionally reduce the available overflow; + // this test only needs enough space to prove the thread body scrolls. + .toBeGreaterThan(0); await expect( timeline.getByTestId("message-row").filter({ hasText: firstReply }), diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0c4d63099..3f6a79dd7 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1280,6 +1280,66 @@ test("first-community direct join reaches profile", async ({ page }) => { .toEqual({ communityCount: 1, transactionMatchesOnlyCommunity: true }); }); +test("community onboarding reuses an existing relay profile", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript( + ({ pubkey, transactionStorageKey }) => { + window.localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${pubkey}`, + "true", + ); + const timestamp = new Date().toISOString(); + window.localStorage.setItem( + transactionStorageKey, + JSON.stringify({ + id: "txn-existing-profile", + source: "add-community", + stage: "profile", + relayUrl: "wss://onboarding.communities.buzz.xyz", + communityName: "Onboarding", + communityId: "e2e-default-community", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + }, + { + pubkey: BLANK_TYLER_IDENTITY.pubkey, + transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, + }, + ); + await installMockBridge( + page, + { profileHasEvent: true }, + { + relayWsUrl: "wss://onboarding.communities.buzz.xyz", + skipOnboardingSeed: true, + }, + ); + await page.goto("/"); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { __BUZZ_E2E_COMMANDS__?: string[] } + ).__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "get_profile", + ).length ?? 0, + ), + ) + .toBeGreaterThan(0); + await expect( + page.getByRole("heading", { name: "Meet your starter team" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toHaveCount(0); +}); + test("first-community direct join cancel returns to request access", async ({ page, }) => { diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts new file mode 100644 index 000000000..edd66170d --- /dev/null +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -0,0 +1,259 @@ +import { expect, test, type Page } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; + +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const CURRENT_PUBKEY = "deadbeef".repeat(8); +const DIFFERENT_PUBKEY = "c0ffee00".repeat(8); +const BACKUP_FILE = { + name: "identity.ncryptsec", + mimeType: "text/plain", + buffer: Buffer.from("ncryptsec1mockbackupmaterial"), +}; + +async function openIdentity(page: Page) { + const identity = page.getByTestId("profile-identity-card"); + if ( + !(await identity.evaluate( + (element) => element instanceof HTMLDetailsElement && element.open, + )) + ) { + await page.getByTestId("profile-identity-toggle").click(); + } +} + +async function openBackupSettings( + page: Page, + mock?: Parameters[1], +) { + await installMockBridge(page, mock); + await page.goto("/"); + await openSettings(page, "profile"); + await openIdentity(page); +} + +async function openPrivateKeyMenu(page: Page) { + const reveal = page.getByTestId("profile-private-key-toggle"); + if ((await reveal.textContent())?.trim() === "Reveal") { + await reveal.click(); + } + await page.getByTestId("nsec-actions").click(); + await expect(page.getByTestId("private-key-create-backup")).toBeVisible(); +} + +async function openCreateBackup(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-create-backup").click(); + const dialog = page.getByTestId("encrypted-backup-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function openTestBackup(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-test-backup").click(); + const dialog = page.getByTestId("backup-test-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function selectBackupFile(page: Page) { + await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE); + await expect(page.getByTestId("backup-test-file-accepted")).toContainText( + BACKUP_FILE.name, + ); +} + +async function verifyBackup(page: Page, password: string) { + await page.getByTestId("backup-test-password").fill(password); + await page.getByTestId("backup-test-verify").click(); +} + +async function backupSaveCallCount(page: Page) { + return page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "save_ncryptsec_copy", + ).length ?? 0, + ); +} + +test("private key menu replaces the backup settings rows", async ({ page }) => { + await openBackupSettings(page); + + await expect(page.getByTestId("profile-encrypted-backup-row")).toHaveCount(0); + await expect(page.getByTestId("profile-backup-test-row")).toHaveCount(0); + + await openPrivateKeyMenu(page); + await expect(page.getByTestId("nsec-copy")).toContainText("Copy"); + await expect(page.getByTestId("private-key-create-backup")).toHaveText( + "Create backup", + ); + await expect(page.getByTestId("private-key-test-backup")).toHaveText( + "Test backup", + ); + + await page.getByTestId("nsec-copy").click(); + await expect(page.getByText(/clipboard$/i)).toBeVisible(); + await expect(page.getByTestId("private-key-create-backup")).toHaveCount(0); + + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-test-backup").click(); + const testDialog = page.getByTestId("backup-test-dialog"); + await expect(testDialog).toContainText("Test a key backup"); + await expect(testDialog.getByText("Select your backup file")).toBeVisible(); + await expect(testDialog).toContainText("standard NIP-49 format"); +}); + +test("creation requires a sufficiently long password and exposes a temporary header download", async ({ + page, +}) => { + await openBackupSettings(page, { + backupSavePaths: [ + "/Users/test/Downloads/identity.ncryptsec", + "/Users/test/Desktop/identity-copy.ncryptsec", + ], + }); + const dialog = await openCreateBackup(page); + + const password = dialog.getByTestId("backup-passphrase-input"); + const submit = dialog.getByTestId("encrypted-backup-create"); + await expect(password).toHaveAttribute( + "placeholder", + "Password (min 12 characters)", + ); + await expect(submit).toBeDisabled(); + await password.fill("short"); + await expect(submit).toBeDisabled(); + + await password.fill("custom password"); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + await expect(dialog).toBeHidden(); + + const keyRow = page.getByTestId("profile-private-key-row"); + const download = keyRow.getByTestId("encrypted-backup-download"); + await expect(download).toBeVisible(); + await expect(download).toHaveText("Download backup"); + await expect(download).toHaveClass(/bg-primary/); + await expect( + download.getByTestId("encrypted-backup-availability-fill"), + ).toBeVisible(); + await expect(keyRow.getByTestId("profile-private-key-toggle")).toBeVisible(); + + await download.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(2); +}); + +test("encryption and native save continue after closing the dialog and settings", async ({ + page, +}) => { + await openBackupSettings(page, { + backupEncryptionDelayMs: 750, + backupSavePaths: [null], + }); + const dialog = await openCreateBackup(page); + await dialog + .getByTestId("backup-passphrase-input") + .fill("background password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await expect(dialog.getByTestId("encrypted-backup-progress")).toBeVisible(); + + await dialog.getByRole("button", { name: "Close" }).click(); + await page.getByTestId("settings-back-to-app").click(); + await expect(page.getByTestId("settings-back-to-app")).toHaveCount(0); + await expect( + page.getByText("Preparing backup…", { exact: true }), + ).toBeVisible(); + + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + const readyToast = page.getByText("Backup ready to download", { + exact: true, + }); + await expect(readyToast).toBeVisible(); + await expect( + page.getByText("Your backup will be available to download for 5 minutes.", { + exact: true, + }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Open settings" }).click(); + await expect(page.getByTestId("settings-back-to-app")).toBeVisible(); + await openIdentity(page); + await expect(page.getByTestId("encrypted-backup-download")).toBeVisible(); +}); + +test("the temporary download expires after five minutes", async ({ page }) => { + await page.clock.install({ time: new Date("2026-07-29T12:00:00Z") }); + await openBackupSettings(page); + const dialog = await openCreateBackup(page); + await dialog.getByTestId("backup-passphrase-input").fill("expiring password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await page.clock.fastForward(1); + + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + const download = page.getByTestId("encrypted-backup-download"); + await expect(download).toHaveText("Download backup"); + await expect( + download.getByTestId("encrypted-backup-availability-fill"), + ).toBeVisible(); + await page.clock.fastForward(5 * 60 * 1000 + 1); + await expect(page.getByTestId("encrypted-backup-download")).toHaveCount(0); +}); + +test("wrong backup password permits a successful retry in the test modal", async ({ + page, +}) => { + await openBackupSettings(page, { + backupVerificationErrors: ["Wrong password.", null], + }); + const dialog = await openTestBackup(page); + await selectBackupFile(page); + + await verifyBackup(page, "wrong password"); + await expect(dialog.getByTestId("backup-test-error")).toHaveText( + "Wrong password.", + ); + await expect(dialog.getByTestId("backup-test-password")).toHaveValue(""); + await expect(dialog.getByTestId("backup-test-verify")).toBeDisabled(); + + await verifyBackup(page, "correct password"); + await expect(dialog.getByTestId("backup-test-success")).toContainText( + "It restores your current Buzz identity.", + ); +}); + +for (const identity of [ + { + label: "current", + pubkey: CURRENT_PUBKEY, + message: "It restores your current Buzz identity.", + }, + { + label: "different", + pubkey: DIFFERENT_PUBKEY, + message: "It restores a different identity than the one signed in here.", + }, +]) { + test(`successful modal verification identifies the ${identity.label} identity using only its npub`, async ({ + page, + }) => { + await openBackupSettings(page, { + backupVerificationPubkeys: [identity.pubkey], + }); + const dialog = await openTestBackup(page); + await selectBackupFile(page); + await verifyBackup(page, "one-time password"); + + const success = dialog.getByTestId("backup-test-success"); + await expect(success).toContainText(identity.message); + await expect(success.getByTestId("backup-test-npub")).toContainText( + npubEncode(identity.pubkey), + ); + await expect(success).not.toContainText(identity.pubkey); + await expect(success).not.toContainText("one-time password"); + await expect(success).not.toContainText(BACKUP_FILE.buffer.toString()); + }); +} diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 6240cca0b..67ce725da 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -49,6 +49,31 @@ async function restartMockWebsockets(page: import("@playwright/test").Page) { expect(restarted).toBeGreaterThan(0); } +async function setMockWebsocketUnavailable( + page: import("@playwright/test").Page, + unavailable: boolean, +) { + await page.evaluate((value) => { + const setUnavailable = window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__; + if (!setUnavailable) { + throw new Error("E2E websocket availability seam is not installed."); + } + setUnavailable(value); + }, unavailable); +} + +async function getMockWebsocketConnectAttempts( + page: import("@playwright/test").Page, +) { + return page.evaluate(() => { + const getAttempts = window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__; + if (!getAttempts) { + throw new Error("E2E websocket attempt seam is not installed."); + } + return getAttempts(); + }); +} + async function emitMockMessages( page: import("@playwright/test").Page, messages: Array<{ content: string; createdAt: number }>, @@ -121,6 +146,55 @@ test("failed initial relay dial retries automatically", async ({ page }) => { await expect(page.getByTestId("channel-general")).toBeVisible(); }); +test("routine traffic cannot bypass outage backoff and recovery stays automatic", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await setMockWebsocketUnavailable(page, true); + await disconnectMockWebsockets(page); + + // Exercise the production query path throughout the outage. Before the + // coordinator fix, each rejected query called ensureConnected(), cancelled + // the scheduled timer, and dialed immediately. The fixed session keeps these + // callers behind its single jittered exponential-backoff attempt. + await page.evaluate(async () => { + const deadline = Date.now() + 4_200; + while (Date.now() < deadline) { + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + }); + + const attempts = await getMockWebsocketConnectAttempts(page); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts.length).toBeLessThanOrEqual(3); + for (let index = 1; index < attempts.length; index += 1) { + expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700); + } + + await setMockWebsocketUnavailable(page, false); + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 10_000 }, + ) + .toBe("connected"); + + const afterRecovery = `automatic outage recovery ${Date.now()}`; + await emitMockMessages(page, [ + { content: afterRecovery, createdAt: Math.floor(Date.now() / 1_000) }, + ]); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("message-timeline")).toContainText( + afterRecovery, + ); +}); + test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"], diff --git a/desktop/tests/e2e/relay-restart.live.spec.ts b/desktop/tests/e2e/relay-restart.live.spec.ts index 057ef46f5..f3c80e69a 100644 --- a/desktop/tests/e2e/relay-restart.live.spec.ts +++ b/desktop/tests/e2e/relay-restart.live.spec.ts @@ -1,8 +1,13 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + import { expect, test, type Page } from "@playwright/test"; -import { installBridge } from "../helpers/bridge"; +import { installBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness"; +const exec = promisify(execFile); + // Live gate: boots a REAL buzz-relay process, points the app at it, SIGTERMs // the relay mid-session, restarts it on the same port, and asserts the client // converges back to "connected". This proves the full restart story end to @@ -20,6 +25,55 @@ function required(name: string, value: string | undefined): string { return value; } +async function runCli(args: string[], relayUrl: string, privateKey: string) { + const binary = required("BUZZ_E2E_CLI_BIN", process.env.BUZZ_E2E_CLI_BIN); + const { stdout } = await exec(binary, args, { + cwd: "..", + env: { + ...process.env, + BUZZ_AUTH_TAG: "", + BUZZ_PRIVATE_KEY: privateKey, + BUZZ_RELAY_URL: relayUrl, + }, + }); + return stdout; +} + +async function seedLiveChannel(relayUrl: string) { + const name = `reconnect-live-${process.pid}`; + const created = JSON.parse( + await runCli( + [ + "channels", + "create", + "--name", + name, + "--type", + "stream", + "--visibility", + "open", + ], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ), + ) as { channel_id: string }; + await runCli( + [ + "channels", + "add-member", + "--channel", + created.channel_id, + "--pubkey", + TEST_IDENTITIES.tyler.pubkey, + "--role", + "member", + ], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ); + return { id: created.channel_id, name }; +} + async function connectionState(page: Page): Promise { return page.evaluate(() => { const win = window as Window & { @@ -29,13 +83,61 @@ async function connectionState(page: Page): Promise { }); } +async function exerciseBackgroundTraffic(page: Page, durationMs: number) { + await page.evaluate(async (duration) => { + const deadline = Date.now() + duration; + while (Date.now() < deadline) { + void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + }, durationMs); +} + +async function resetConnectAttempts(page: Page) { + await page.evaluate(() => { + window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?.(); + }); +} + +async function assertConnectAttemptsArePaced(page: Page) { + const attempts = await page.evaluate( + () => window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?.() ?? [], + ); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts.length).toBeLessThanOrEqual(4); + for (let index = 1; index < attempts.length; index += 1) { + expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700); + } +} + +async function proveLiveDelivery( + page: Page, + relayUrl: string, + channel: { id: string; name: string }, + label: string, +) { + await page.getByTestId(`channel-${channel.name}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channel.name); + const message = `${label} ${Date.now()}`; + await runCli( + ["messages", "send", "--channel", channel.id, "--content", message], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ); + await expect(page.getByTestId("message-timeline")).toContainText(message, { + timeout: 30_000, + }); +} + test.describe("relay restart live gate", () => { test.skip(!enabled, "set BUZZ_E2E_RELAY_RESTART=1 to run live gate"); test("client reconnects after the relay is SIGTERMed and restarted", async ({ page, }) => { - test.setTimeout(180_000); + test.setTimeout(240_000); const portBase = 26_000 + (process.pid % 3_000); const spec: RelaySpec = { name: "relay-restart", @@ -56,6 +158,8 @@ test.describe("relay restart live gate", () => { await harness.startRelays(); const relayHttpUrl = `http://127.0.0.1:${spec.ports.main}`; + const channel = await test.step("seed live channel and membership", () => + seedLiveChannel(relayHttpUrl)); await installBridge(page, { mode: "relay", user: "tyler", @@ -64,28 +168,68 @@ test.describe("relay restart live gate", () => { }); await page.goto("/"); - // Baseline: the app converges to a live authenticated session. - await expect - .poll(() => connectionState(page), { timeout: 60_000 }) - .toBe("connected"); + // Baseline: the app converges to a live authenticated session and sees + // the channel created for this fresh database. + await test.step("wait for initial authenticated connection", async () => { + await expect + .poll(() => connectionState(page), { timeout: 60_000 }) + .toBe("connected"); + await expect(page.getByTestId(`channel-${channel.name}`)).toBeVisible({ + timeout: 30_000, + }); + }); // Roll the pod. Graceful drain: readiness 503 → 5s grace → 1012 close // broadcast → process exit. The client must observe the close (not a // silent stall) and start retrying. + await resetConnectAttempts(page); await harness.terminateRelayGracefully(spec.name); await expect .poll(() => connectionState(page), { timeout: 30_000 }) .not.toBe("connected"); + // Keep the real relay unavailable across several reconnect windows while + // ordinary app traffic continues. This is the production-shaped race: + // background queries must not bypass the session coordinator's backoff. + await exerciseBackgroundTraffic(page, 8_000); + await expect.poll(() => connectionState(page)).not.toBe("connected"); + await assertConnectAttemptsArePaced(page); + // Bring the "new pod" up on the same address, exactly like a k8s // restart behind a stable service endpoint. await harness.restartRelay(spec.name); // The client's retry loop must find the fresh relay and converge back - // to connected without any user interaction. + // to connected without any user interaction, then prove that AUTH and + // live-subscription replay finished by receiving an event published by + // a second identity through the real CLI/relay boundary. await expect .poll(() => connectionState(page), { timeout: 60_000 }) .toBe("connected"); + await proveLiveDelivery( + page, + relayHttpUrl, + channel, + "first automatic recovery", + ); + + // Flap the fresh pod once more. A second recovery catches stale timer, + // waiter, generation, and subscription state that a single cycle cannot. + await harness.terminateRelayGracefully(spec.name); + await expect + .poll(() => connectionState(page), { timeout: 30_000 }) + .not.toBe("connected"); + await exerciseBackgroundTraffic(page, 4_000); + await harness.restartRelay(spec.name); + await expect + .poll(() => connectionState(page), { timeout: 60_000 }) + .toBe("connected"); + await proveLiveDelivery( + page, + relayHttpUrl, + channel, + "second automatic recovery", + ); } catch (error) { console.error(await harness.logs()); throw error; diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 8f4bb0aa4..458689a5e 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -53,25 +53,31 @@ function emitMockMessage( page: Page, channelName: string, content: string, - options: { extraTags?: string[][] } = {}, + options: { extraTags?: string[][]; parentEventId?: string } = {}, ) { return page.evaluate( - ({ channelName, content, extraTags }) => { + ({ channelName, content, extraTags, parentEventId }) => { const emit = ( window as Window & { __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { channelName: string; content: string; extraTags?: string[][]; + parentEventId?: string; }) => unknown; } ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; if (!emit) { throw new Error("Mock message emitter is unavailable."); } - emit({ channelName, content, extraTags }); + return emit({ channelName, content, extraTags, parentEventId }); + }, + { + channelName, + content, + extraTags: options.extraTags, + parentEventId: options.parentEventId, }, - { channelName, content, extraTags: options.extraTags }, ); } @@ -765,6 +771,55 @@ test("video upload previews use poster frames and inline videos open review mode ).toContainText("Color pass looks right"); }); +test("video replies in threads open the review comments view", async ({ + page, +}) => { + await installVideoReviewHarness(page); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + const root = (await emitMockMessage( + page, + "general", + "Can you review this cut?", + )) as { id: string }; + const videoReply = (await emitMockMessage( + page, + "general", + `![video](${VIDEO_URL})`, + { + parentEventId: root.id, + }, + )) as { id: string }; + await emitMockMessage(page, "general", "[00:01] Tighten this transition.", { + parentEventId: videoReply.id, + }); + + const threadSummary = page.locator(`[data-thread-head-id="${root.id}"]`); + await expect(threadSummary).toBeVisible(); + await threadSummary.click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadReplies = threadPanel.getByTestId("message-thread-replies"); + const reviewButton = threadReplies.getByRole("button", { + name: "Open video review", + }); + await expect(reviewButton).toBeVisible(); + await reviewButton.click(); + + const reviewDialog = page.getByTestId("video-review-dialog"); + await expect( + reviewDialog.getByTestId("video-review-comments-panel"), + ).toBeVisible(); + await expect(reviewDialog.getByTestId("message-composer")).toBeVisible(); + await expect(reviewDialog.getByTestId("video-review-comments")).toContainText( + "Tighten this transition.", + ); +}); + test("narrow inline videos hide playback speed control", async ({ page }) => { await installVideoReviewHarness(page); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ca4d62ddd..468f86020 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -268,6 +268,8 @@ type MockBridgeOptions = { channelWindowDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; + /** Override whether get_profile reports a real kind:0 event. */ + profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; searchProfiles?: MockSearchProfileSeed[]; @@ -439,6 +441,14 @@ type MockBridgeOptions = { /** Delay (ms) for `set_global_agent_config` — hold saves open in tests. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message. For a single always-fail * scenario. Use `nsecErrors` for sequenced fail/succeed. diff --git a/docs/nips/NIP-MP.fixtures.json b/docs/nips/NIP-MP.fixtures.json new file mode 100644 index 000000000..cfc570ec6 --- /dev/null +++ b/docs/nips/NIP-MP.fixtures.json @@ -0,0 +1,1462 @@ +{ + "$comment": "NIP-MP conformance fixtures \u2014 the shared ingest contract for the relay validator, the Rust event builder, and the TypeScript event builder. Each case carries an UNSIGNED template: consumers sign it with their own test key, because signing fixes the event id and signature and a stored literal would not survive re-serialization. `expect` is the ingest outcome. `reject_rules` lists the validation rules that may fire; an implementation must reject for one of them, so it cannot pass by rejecting for an unrelated reason. This file covers single-event accept/reject only; the client-side fold has its own oracle in NIP-MP.fold-fixtures.json. Spec: docs/nips/NIP-MP.md.", + "version": 1, + "kind": 30621, + "member_cap": 64, + "owners": { + "a": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "b": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "cases": [ + { + "name": "valid_minimal", + "expect": "accept", + "note": "Only the identity tag. A project needs nothing but a `d` tag to be a valid addressable container.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ] + ] + } + }, + { + "name": "valid_full", + "expect": "accept", + "note": "Every specified tag present, with two members owned by two different pubkeys \u2014 the cross-owner grouping that motivates the kind.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ], + [ + "description", + "Relay, desktop, and mobile for the platform team." + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-infra" + ], + [ + "buzz-channel", + "3580ca9b-47b4-4af9-b22a-1068778f26c6" + ], + [ + "buzz-visibility", + "listed" + ] + ] + } + }, + { + "name": "valid_zero_members", + "expect": "accept", + "note": "Zero-member project. Legal at the protocol layer: it is the natural state after removing a final member and carries only bounded metadata.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ] + ] + } + }, + { + "name": "valid_unlisted", + "expect": "accept", + "note": "Explicitly unlisted container. Accepted at ingest; the fold excludes it from listing eligibility, so its member keeps its implicit card.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "skunkworks" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "buzz-visibility", + "unlisted" + ] + ] + } + }, + { + "name": "valid_member_cap_boundary", + "expect": "accept", + "note": "Exactly 64 distinct member `a` tags \u2014 the cap is inclusive. Paired with `invalid_member_cap_exceeded` to pin the boundary from both sides.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63" + ] + ] + } + }, + { + "name": "valid_same_dtag_two_owners", + "expect": "accept", + "note": "Two members share a repo `d` segment under different owners (the NIP-34 fork case). Identity is the whole coordinate, so these are not duplicates.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "forks" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz" + ] + ] + } + }, + { + "name": "valid_member_dtag_contains_colon", + "expect": "accept", + "note": "Repo `d` segment contains a colon. The coordinate splits into at most three parts, so the third part is the repo `d` value verbatim \u2014 `buzz:infra` here. Splitting on every colon instead would make any repo with a colon in its `d` tag unaddressable by a project.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz:infra" + ] + ] + } + }, + { + "name": "valid_uninterpreted_metadata_values", + "expect": "accept", + "note": "`buzz-channel` is not a UUID and `buzz-visibility` is an unrecognized token. Ingest bounds metadata cardinality and length but does not interpret these values \u2014 matching how kind:30617 carries the same two tags. Client-side fallbacks for both are normative in the spec's Metadata interpretation section: an unresolvable channel renders the project without one, and an unrecognized visibility is treated as `listed`.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "not-a-uuid" + ], + [ + "buzz-visibility", + "chartreuse" + ] + ] + } + }, + { + "name": "valid_unknown_tag_ignored", + "expect": "accept", + "note": "An unrecognized tag is neither rejected nor interpreted \u2014 unknown tags are ignored, not fatal.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "future-metadata", + "preserve-me" + ] + ] + } + }, + { + "name": "valid_member_relay_hint", + "expect": "accept", + "note": "Member `a` tag carries NIP-01's optional third element, a relay hint. Ingest validates the tag's arity and the coordinate in element 1, so the hint's content is neither parsed nor a rejection cause; a client MAY use it to resolve an otherwise unavailable member.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay.example.com" + ] + ] + } + }, + { + "name": "valid_non_empty_content", + "expect": "accept", + "note": "`content` holds a value. Writers should emit the empty string, but readers and relays must ignore whatever is there \u2014 a non-empty `content` is not a rejection cause and carries no semantics.", + "template": { + "kind": 30621, + "content": "ignored by every consumer", + "tags": [ + [ + "d", + "platform" + ] + ] + } + }, + { + "name": "invalid_d_missing", + "expect": "reject", + "note": "No `d` tag. Without one the event collapses into the `(pubkey, 30621, \"\")` slot and silently last-write-wins over an unrelated project.", + "reject_rules": [ + "d-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "name", + "Platform" + ] + ] + } + }, + { + "name": "invalid_d_multiple", + "expect": "reject", + "note": "Two `d` tags. Which one addresses the event is reader-dependent; reject rather than pick.", + "reject_rules": [ + "d-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "d", + "infra" + ] + ] + } + }, + { + "name": "invalid_d_empty", + "expect": "reject", + "note": "Present but empty `d`. Same slot-collapse hazard as a missing `d`.", + "reject_rules": [ + "d-empty" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "" + ] + ] + } + }, + { + "name": "invalid_member_duplicate", + "expect": "reject", + "note": "The same canonical coordinate twice. A signed event cannot be normalized in place, so the relay refuses it rather than store a head every consumer must defensively dedupe.", + "reject_rules": [ + "member-duplicate" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_duplicate_differing_relay_hints", + "expect": "reject", + "note": "The same coordinate twice under different relay hints. Duplicate detection compares the coordinate alone, so differing hints do not make these distinct members; a validator that compares whole tag arrays would wrongly accept this.", + "reject_rules": [ + "member-duplicate" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay-one.example.com" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay-two.example.com" + ] + ] + } + }, + { + "name": "invalid_member_cap_exceeded", + "expect": "reject", + "note": "65 distinct member `a` tags \u2014 one past the inclusive cap of 64.", + "reject_rules": [ + "member-cap" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-64" + ] + ] + } + }, + { + "name": "invalid_member_cap_exceeded_by_duplicates", + "expect": "reject", + "note": "65 member `a` tags naming 33 distinct coordinates. The spec evaluates the cap before the duplicate set is built, so `member-cap` is the required rule even though the event also carries duplicates; a validator that reports `member-duplicate` here has built a set whose size is bounded only by the frame limit.", + "reject_rules": [ + "member-cap" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ] + ] + } + }, + { + "name": "invalid_member_tag_arity_four_elements", + "expect": "reject", + "note": "Member `a` tag carries a fourth element past the relay hint. Its coordinate is valid, so this rejects on tag shape rather than on the coordinate: NIP-01's `a` grammar is two or three elements, and a validator that reads element 1 and ignores the rest would accept unbounded unvalidated data in a position no consumer reads.", + "reject_rules": [ + "member-tag-arity" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay.example.com", + "unexpected" + ] + ] + } + }, + { + "name": "invalid_member_kind_prefix", + "expect": "reject", + "note": "Coordinate names kind 30618 (repo state) rather than 30617 (repo announcement). A project groups repositories, not their ref state.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30618:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_not_hex", + "expect": "reject", + "note": "Owner segment is 64 characters but not hex.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_uppercase", + "expect": "reject", + "note": "Owner segment is uppercase hex. `#a` tag matching is byte-exact, so an uppercase head is invisible to the lowercase-coordinate queries every reader issues.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_wrong_length", + "expect": "reject", + "note": "Owner segment is 63 hex characters.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_dtag_empty", + "expect": "reject", + "note": "Coordinate has an empty repo `d` segment \u2014 it addresses no announceable repository.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + ] + ] + } + }, + { + "name": "invalid_member_missing_segment", + "expect": "reject", + "note": "Coordinate has two segments instead of three.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + ] + } + }, + { + "name": "invalid_metadata_duplicate_name", + "expect": "reject", + "note": "Two `name` tags. Reader-dependent display name; reject rather than pick.", + "reject_rules": [ + "metadata-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ], + [ + "name", + "Infra" + ] + ] + } + }, + { + "name": "invalid_metadata_duplicate_channel", + "expect": "reject", + "note": "Two `buzz-channel` tags. Which channel the project links to would be reader-dependent.", + "reject_rules": [ + "metadata-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "3580ca9b-47b4-4af9-b22a-1068778f26c6" + ], + [ + "buzz-channel", + "00000000-0000-0000-0000-000000000000" + ] + ] + } + }, + { + "name": "invalid_metadata_name_too_long", + "expect": "reject", + "note": "`name` value exceeds 256 bytes.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ] + } + }, + { + "name": "invalid_metadata_description_too_long", + "expect": "reject", + "note": "`description` value exceeds 2048 bytes.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "description", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ] + } + }, + { + "name": "invalid_metadata_channel_too_long", + "expect": "reject", + "note": "`buzz-channel` value exceeds 256 bytes. Paired with `invalid_metadata_visibility_too_long` so each bound is exercised by an event whose other metadata is valid \u2014 neither check can hide behind the other's rejection.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + ] + } + }, + { + "name": "invalid_metadata_visibility_too_long", + "expect": "reject", + "note": "`buzz-visibility` value exceeds 256 bytes. Ingest does not interpret the token, but it must still bound its length.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-visibility", + "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv" + ] + ] + } + } + ] +} diff --git a/docs/nips/NIP-MP.fold-fixtures.json b/docs/nips/NIP-MP.fold-fixtures.json new file mode 100644 index 000000000..c036646c3 --- /dev/null +++ b/docs/nips/NIP-MP.fold-fixtures.json @@ -0,0 +1,527 @@ +{ + "$comment": "NIP-MP fold fixtures \u2014 the shared oracle for the client-side fold in docs/nips/NIP-MP.md (\"The fold\" and \"Required fold cases\"). Every case in the spec's required-cases table appears here, keyed by `spec_case`. Inputs are SEMANTIC, not signed envelopes: a repository or project is named by its coordinate plus the fold's inputs (signer, members, `maintainers`, visibility, viewer-hidden, deletion). Signing and envelope validation are the ingest contract and live in NIP-MP.fixtures.json; this file assumes every input is a valid, accepted head and tests only the placement the fold derives from it. `expect.containers` lists each rendered project with the members rendered inside it; `expect.implicit_cards` lists the repositories that additionally render as their own single-repository cards. EVERY collection in `expect` is compared as a set \u2014 the containers, each container's `members`, and the implicit cards alike \u2014 because the fold fixes placement, not order. `render` is `resolved` when the member coordinate resolves to a live head and `unavailable` when it does not. `state` is `live` or `deleted`; `visibility` is `listed` or `unlisted`; a value of `viewer_hidden` is a local, per-viewer decision, not event state.", + "version": 1, + "project_kind": 30621, + "repository_kind": 30617, + "pubkeys": { + "alice": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bob": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "carol": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "cases": [ + { + "name": "owner_project_claims_own_repository", + "spec_case": "Owner's own project lists their repository", + "note": "The signer is the member coordinate's owner, so the project claims the repository and step 3 suppresses its implicit card.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "stranger_project_does_not_claim", + "spec_case": "Stranger's project lists someone else's repository", + "note": "Bob is neither the owner nor a maintainer, so his project renders the member but claims nothing. The repository keeps its own card: an unendorsed grouping cannot pull a repository out of where its owner expects to find it.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed", + "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "maintainer_signer_claims", + "spec_case": "Project signer is in the member repository's `maintainers` tag", + "note": "Claim authority is read from the member repository's own head, not from ownership alone. Carol is listed in `maintainers`, so her project claims the repository exactly as the owner's would.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false, + "maintainers": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + } + ], + "projects": [ + { + "coordinate": "30621:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:platform", + "signer": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "two_claiming_projects_both_render_member", + "spec_case": "Repository is a member of two projects that both claim it", + "note": "Multiple membership is not a move. The repository renders inside both containers and has no implicit card, and it appears once per container rather than twice in either.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + }, + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:infra", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + }, + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:infra", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "repository_removed_from_every_project", + "spec_case": "Repository removed from every project", + "note": "A live project that no longer lists the repository. The container renders empty rather than being hidden, and the unclaimed repository falls back to its own card.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [] + } + ], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "unlisted_project_absent_member_falls_back", + "spec_case": "Project is `unlisted`, or locally hidden", + "note": "An unlisted project is not listing eligible, so it claims nothing even though its signer owns the member. The container that would hold the repository is not on screen, so the repository must render as its own card or vanish.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:skunkworks", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "unlisted", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "locally_hidden_project_absent_member_falls_back", + "spec_case": "Project is `unlisted`, or locally hidden", + "note": "The second half of the same rule, reached by a different input: hiding a container is a statement about the grouping only. The viewer-hidden project claims nothing and its member returns as an implicit card.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": true + } + ], + "expect": { + "containers": [], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "viewer_hidden_repository_absent_everywhere", + "spec_case": "Viewer has hidden a member repository", + "note": "Hiding a repository hides it everywhere, including inside every project that lists it \u2014 otherwise someone else's grouping could undo the viewer's decision. The sibling member is unaffected.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": true + }, + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "unresolvable_member_renders_unavailable", + "spec_case": "Member coordinate resolves to nothing", + "note": "No repository answers the coordinate \u2014 never announced, deleted, or absent from this relay. It renders inside its project as explicitly unavailable: dropping it silently would make the project look smaller than its author declared, and promoting it to a standalone card would invent a repository.", + "repositories": [], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:ghost" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:ghost", + "render": "unavailable" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "deleted_project_absent_member_falls_back", + "spec_case": "Project head deleted", + "note": "Deletion does not cascade. The container is gone; the member repository, its refs and its channel survive and it falls back to an implicit card.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "deleted", + "viewer_hidden": false + } + ], + "expect": { + "containers": [], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "authorized_and_unauthorized_projects_share_member", + "spec_case": "One authorized and one unauthorized project both list the same repository", + "note": "The discriminating case for step 3's \"at least one\": Alice's claim suppresses the implicit card, and Bob's unauthorized project still renders the member. An implementation that requires every listing project to be authorized would wrongly emit an implicit card here; one that lets any listing project suppress would wrongly emit none in `stranger_project_does_not_claim`.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + }, + { + "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed", + "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + }, + { + "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "exhaustive_enumeration_across_pages_with_tied_timestamps", + "spec_case": "More repositories and projects than one page holds, with several sharing one `created_at`", + "note": "Exercises step 1 rather than the placement rules: the inputs exceed `enumeration.page_size` and three entities share one `created_at`, so a timestamp-only cursor loses events at the page boundary. Every repository and project must still render. `created_at` is present only on this case, and only because the cursor is what is under test.", + "enumeration": { + "page_size": 2, + "cursor": "composite", + "note": "The harness must serve inputs in pages of `page_size`, ordered `(created_at DESC, coordinate ASC)`, and the client under test must page with a composite `(created_at, id)` cursor per the Pagination section. Ordering by coordinate stands in for event id, which unsigned semantic fixtures do not carry." + }, + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false, + "created_at": 1000 + }, + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra", + "state": "live", + "viewer_hidden": false, + "created_at": 1000 + }, + { + "coordinate": "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile", + "state": "live", + "viewer_hidden": false, + "created_at": 1000 + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false, + "created_at": 900 + }, + { + "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:mobile", + "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "members": [ + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false, + "created_at": 900 + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + }, + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra", + "render": "resolved" + } + ] + }, + { + "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:mobile", + "members": [ + { + "coordinate": "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + } + ] +} diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md new file mode 100644 index 000000000..6ca1ec474 --- /dev/null +++ b/docs/nips/NIP-MP.md @@ -0,0 +1,331 @@ +NIP-MP +====== + +Multi-Repository Projects +------------------------- + +`draft` `optional` `relay` + +**Depends on**: NIP-01 (basic event format, addressable events), NIP-34 (git repositories), NIP-09 (event deletion). Interacts with NIP-29 (the channel a project links to) and NIP-OA (owner attestation, for how agents inherit repo push access). + +## Abstract + +This NIP defines `kind:30621`, an addressable **project** event: a signed, named grouping of NIP-34 repository announcements (`kind:30617`). A project references its member repositories by coordinate, so one project may span repositories owned by different pubkeys, and one repository may belong to several projects. + +A project is metadata only. Its signer gains no authority over any member repository — not to edit it, delete it, push to it, or administer it. Membership is an assertion about grouping, not a grant of permission. + +## Motivation + +Buzz renders one card per `kind:30617`, so "the platform" — a relay, a desktop app, and a mobile app — appears as three unrelated repositories. Real work spans repositories; the model does not. + +[VISION_PROJECTS.md](../../VISION_PROJECTS.md) sets the bar as "standard kinds as substrate, custom kinds only where genuinely novel," and every other forge concept in Buzz clears it: repositories, patches, issues, statuses, and ref state are all standard NIP-34 kinds. Multi-repository grouping is the one semantic that cannot be: + +- **Per-repository tags cannot express cross-owner grouping.** If membership lived in each `kind:30617`, a project spanning Alice's and Bob's repositories would require *both* Alice and Bob to publish a tag naming the group. Alice cannot enroll Bob's repository; she cannot sign for his key. Grouping would be possible only within a single owner's repositories, and would break the moment a repository changed hands or a fork joined. +- **Project-level metadata has no owner.** A project name, description, and linked channel describe the *group*, not any one repository. Scattered across per-repository tags they have no single writer, no replacement semantics, and no deletion story: removing a repository from the group means editing an event you may not control. +- **Existing list kinds do not fit.** NIP-51 sets (`kind:30004` curation sets and friends) are private-or-public user bookmarks over arbitrary content, not a shared, named, addressable container for a forge collection with its own channel binding and visibility. Overloading a curation set would make every project indistinguishable from a user's reading list. + +One custom kind, held by one signer, with all group state in one replaceable event, resolves all three. The cost is bounded and stated plainly: `kind:30621` is Buzz-specific, so a third-party NIP-34 client sees the member repositories individually and ignores the grouping. Nothing degrades — the repositories remain standard, portable `kind:30617` events, discoverable and renderable exactly as before. + +## Non-Goals + +This NIP does not define shared or delegated project editing — a project is replaceable only by its own signer (see [Authority](#authority)). +This NIP does not define any authorization over member repositories. Membership is not a permission grant, and a project is never consulted by git push policy. +This NIP does not define project-level branch protection, CI, or workflow configuration. +This NIP does not define nested projects. A project's members are repositories, never other projects. +This NIP does not require relays to verify that a member coordinate resolves to an existing repository — a project may reference a repository that does not exist yet, or no longer does. + +## Terminology + +This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as defined in RFC 2119. + +- **project**: A `kind:30621` event. Also called the *container*. +- **member**: A repository referenced by a project, named by an `a` tag holding a repository coordinate. +- **coordinate**: The NIP-01 address of a repository announcement, `30617::`. +- **explicit project**: A project that exists as a `kind:30621` event. +- **implicit project**: The single-repository card a client renders for a `kind:30617` that no listing-eligible explicit project claims. Not an event — a rendering fallback. +- **listing eligible**: A project a client is currently rendering in its project collection. See [Listing eligibility](#listing-eligibility). + +## Kinds + +| Kind | Name | Signer | Class | Purpose | +|------|------|--------|-------|---------| +| `30621` | Project | user | addressable | A named grouping of `kind:30617` repository announcements | + +`kind:30621` is an addressable event per NIP-01 (`30000 <= n < 40000`), addressed by `(pubkey, 30621, d)`. Two signers may use the same `d` value; those are two distinct projects. Addressable events were formerly specified as "parameterized replaceable events" in NIP-33, which upstream has since folded into NIP-01; this document cites NIP-01 throughout. + +### Kind allocation + +`30621` sits in the NIP-34 git block (`30617` repository announcement, `30618` repository state), which is where a reader looks for a forge concept. Checks performed before freezing the number: + +| Registry | Checked | Result | +|----------|---------|--------| +| Upstream nostr NIPs event-kind table (`nostr-protocol/nips` `README.md`, at commit `6d2979b3f503a8539c983efbcdcf901bbcf9ed23`) | `30610`–`30629` | Only `30617` and `30618` are assigned. `30621` is unassigned. | +| nostrbook.dev kind registry (`https://nostrbook.dev/kinds/`) | `30617`, `30618`, `30620`, `30621`, `30622` | `30617` and `30618` documented (HTTP 200). `30620`, `30621`, `30622` all HTTP 404 — no entry. | +| This repository (`crates/buzz-core/src/kind.rs`) | full range | `30620` is `KIND_WORKFLOW_DEF`, `30622` is `KIND_DM_VISIBILITY` (NIP-DV). `30621` is the one free number between them. | + +Both external registries are advisory, not authoritative allocators: neither reserves numbers, and an unregistered kind may still be in use by an unpublished client. A future upstream assignment of `30621` would be a collision Buzz absorbs the same way it already does for its other custom kinds — the number is Buzz-specific, and interoperability rests on the member `kind:30617` events, which remain standard. + +## Event Format + +```jsonc +{ + "kind": 30621, + "pubkey": "", + "content": "", + "tags": [ + ["d", "platform"], + ["name", "Platform"], + ["description", "Relay, desktop, and mobile for the platform team."], + ["a", "30617::buzz"], + ["a", "30617::buzz-infra"], + ["buzz-channel", ""], + ["buzz-visibility", "listed"] + ] +} +``` + +| Tag | Cardinality | Meaning | +|-----|-------------|---------| +| `d` | exactly 1, non-empty | Project slug. The NIP-01 addressable identifier. | +| `name` | 0 or 1 | Human-readable display name. Clients fall back to `d` when absent. | +| `description` | 0 or 1 | Free text describing the project. | +| `a` | 0 to 64 | One member repository coordinate each. Order is not significant. | +| `buzz-channel` | 0 or 1 | UUID of the channel this project's discussion lives in. Metadata only — see [Authority](#authority). At most 256 bytes. | +| `buzz-visibility` | 0 or 1 | `listed` (default) or `unlisted`. Feeds [listing eligibility](#listing-eligibility). At most 256 bytes. | + +`content` carries no meaning. Writers SHOULD emit the empty string. Readers and relays MUST ignore whatever it holds: a non-empty `content` is not a rejection cause, and no consumer may parse semantics from it. Reserving it costs nothing and keeps a future writer that fills it from invalidating its events for today's readers. + +Unrecognized tags MUST be ignored rather than rejected, so a newer writer can add metadata without invalidating its events for older readers. + +### Metadata interpretation + +Ingest bounds metadata cardinality and length; it interprets no metadata value. `buzz-channel` and `buzz-visibility` are opaque strings to a relay, exactly as they are on `kind:30617`. Interpretation is a client concern, and every client MUST resolve it the same way: + +- `name` absent → clients display the `d` value. +- `buzz-visibility` absent or holding any value other than `listed` or `unlisted` → treated as `listed`. An unrecognized token MUST NOT hide a project: a typo in a metadata field is not a privacy signal, and treating it as one would make a project vanish for reasons its author cannot see. +- `buzz-channel` absent, or naming a channel the viewer cannot resolve or read → the project renders without a channel link. It MUST NOT be dropped from the collection, and the unresolvable value MUST NOT be surfaced as a broken link. + +### Member coordinates + +A member `a` tag follows NIP-01's `a` tag grammar: `["a", ""]` or `["a", "", ""]`. Ingest validates the tag's arity — exactly two or three elements — and the coordinate in element 1. The relay URL is opaque: it is never parsed and never a rejection cause by content. A fourth element has no meaning in this grammar and is rejected rather than ignored, so a writer cannot smuggle unbounded data into a position no consumer reads. + +The optional third element is a **relay hint**: a recommended relay where the member announcement may be found. Clients MAY use it when resolving a member that step 6 of [the fold](#the-fold) would otherwise mark unavailable, and MUST treat it as advice rather than authority — a hint is unauthenticated, supplied by the project signer rather than the repository owner, so a resolution through it MUST still verify that the retrieved event is the coordinate's own signed `kind:30617`. A hint MUST NOT be required: a project whose members are all on the reading relay resolves fully without one, and a client that ignores hints entirely is conformant. + +A member `a` tag coordinate MUST be exactly `30617::` where: + +- the kind segment is the literal `30617`. A project groups repository *announcements*; a coordinate naming any other kind (notably `30618` repository state) is malformed. +- `` is 64 lowercase hex characters. Uppercase is rejected: `#a` filter matching is byte-exact, so an uppercase-owner head would be invisible to the lowercase-coordinate queries every reader issues. +- `` is non-empty and is the `d` tag of the member repository announcement, taken **verbatim**. + +Parsing splits on the first two colons only; everything after the second colon is ``. A repository whose `d` tag contains a colon is therefore addressable. Splitting on every colon would make such a repository permanently unaddressable by any project. + +Buzz-hosted repositories cannot currently produce such a coordinate: their `d` values are validated as `[a-zA-Z0-9._-]{1,64}` (`crates/buzz-relay/src/handlers/side_effects.rs`, `crates/buzz-sdk/src/builders.rs`). The tolerance is for the repositories this NIP does not control — NIP-34 announcements from other clients, and any future relaxation of Buzz's own rule — and it matches how Buzz already parses coordinates in NIP-09 deletion handling, so a project coordinate and a deletion coordinate can never disagree about where a repository's `d` value begins. + +Coordinate identity is the whole string. Two members sharing a `` under different owners — the NIP-34 fork case — are distinct members, not duplicates. + +A project MAY reference a coordinate that resolves to nothing: a repository not yet announced, deleted, or announced on another relay. Clients render those members as explicitly unavailable ([Client Behavior](#client-behavior), step 6). + +## Semantics + +### Authority + +The project signer's authority begins and ends at the container. + +- **Over the container**: total. Only the signer can replace their `(pubkey, 30621, d)` coordinate. Deletion additionally admits the signer's registered NIP-OA owner — see [Deletion](#deletion). +- **Over member repositories**: none. No edit, no delete, no push, no administration, no ability to change a member repository's own metadata or protections. Adding Bob's repository to Alice's project changes nothing about Bob's repository or who may push to it. It is Alice's signed assertion that the two belong together, and it is attributable to her key. + +Clients MUST preserve each member repository's own owner provenance in the UI. A repository rendered inside a project must not appear to be owned or governed by the project signer. + +`buzz-channel` on a project is **metadata only**. Git push policy reads the `buzz-channel` of the repository's own `kind:30617` (`crates/buzz-relay/src/api/git/policy.rs`); a project neither overrides that binding nor supplies one to a member that lacks it. A project's channel binding therefore cannot widen or narrow push access to anything. + +### Editing model + +Editing is **owner-only**: publish a replacement `kind:30621` with the same `d` and a newer `created_at`. Adding, removing, or reordering members and changing metadata are all one operation — replacing the container. This falls out of the addressable-event model with no relay-side permission machinery; NIP-01 replacement already refuses to let one pubkey overwrite another's coordinate. + +Delegated or maintainer editing is deliberately out of scope for this version. Adding it later needs no change to this event shape — only a new rule about who may replace a coordinate. + +### Zero-member projects + +A project with no `a` tags is valid. It is the natural state after removing a final member, and it carries only bounded metadata either way. Deleting the container — with its name, description, and channel binding — because its last repository was removed would be a destructive surprise for a reversible action. + +Clients SHOULD require at least one member when *creating* a project, since an empty new project is almost always a mistake, and MUST render an existing empty project as an empty container rather than hiding it or treating it as malformed. + +### Multiple membership + +A repository may be a member of any number of projects. It renders inside each ([Client Behavior](#client-behavior), step 4). Membership is not exclusive and not a move: nothing about the repository event changes when it joins or leaves a project. + +### Deletion + +Deleting a project (NIP-09 `kind:5` naming the project coordinate) deletes the `kind:30621` only. Member repositories are untouched — their `kind:30617` events, refs, channels, and protections all survive, and each falls back to an implicit card unless another listing-eligible project claims it. + +**Who may delete.** The project signer always may. On the Buzz relay, so may the signer's registered NIP-OA owner: `validate_standard_deletion_event` resolves the deletion's effective author and accepts it when that actor is the target pubkey's registered owner (`crates/buzz-relay/src/handlers/side_effects.rs`). This is a **Buzz relay extension to NIP-09**, applied uniformly to every kind rather than specially to projects — it is what lets a human clean up events published by an agent they own. Vanilla NIP-09 relays accept only the signer, so a project deleted through the owner path on Buzz will still be live on a relay that lacks the extension. + +Replacement admits no such widening: it is signer-only on every relay, because NIP-01 keys the coordinate on the pubkey itself rather than on a permission check. + +A deletion whose `created_at` precedes the live head does not remove it — see [Relay Processing Algorithm](#relay-processing-algorithm). + +There is no cascade, in either direction. Deleting a member repository does not modify the project; the project keeps a coordinate that no longer resolves, and clients render it as unavailable. + +## Relay Processing Algorithm + +A relay accepting `kind:30621` MUST validate the envelope at ingest. The rule names below are the identifiers the shared fixtures use. + +1. **`d-cardinality`** — exactly one `d` tag. Zero or several is rejected. Under NIP-01 a missing `d` is treated as empty, which collapses every such event into the `(pubkey, 30621, "")` slot where unrelated projects silently overwrite each other; several `d` tags make the address reader-dependent. +2. **`d-empty`** — the `d` value is non-empty. Same collapse hazard. Its length is bounded by the relay's existing generic `d`-tag limit (`buzz_db::event::D_TAG_MAX_LEN`, 1024 bytes); this NIP adds no second bound. +3. **`member-cap`** — at most 64 member `a` tags, counting **every** `a` tag rather than distinct coordinates. Counting distinct coordinates would leave parse volume bounded only by the relay frame limit (512 KiB by default, `crates/buzz-relay/src/config.rs`), since a duplicate-heavy event could carry thousands of tags naming one coordinate. The cap is inclusive: 64 is accepted, 65 is not. +4. **`member-tag-arity`** — every member `a` tag has exactly two or three elements, per NIP-01's `a` tag grammar. A one-element tag names no coordinate; a fourth element has no defined meaning, and ignoring it would let a writer park unbounded unvalidated data in a position no consumer reads. This is a separate rule from the next one because the failure is different: the tag's shape is wrong, not the coordinate it holds. +5. **`member-coordinate-malformed`** — every member `a` tag's coordinate (element 1) parses per [Member coordinates](#member-coordinates). The relay hint in element 3 is not parsed and MUST NOT be a rejection cause by its content. +6. **`member-duplicate`** — no two member `a` tags hold the same coordinate, compared as exact strings on the canonical form. Comparison is on the coordinate alone, so two tags naming one coordinate with different relay hints are duplicates. +7. **`metadata-cardinality`** — at most one each of `name`, `description`, `buzz-channel`, `buzz-visibility`. Duplicates would make the effective value reader-dependent. +8. **`metadata-length`** — `name` at most 256 bytes; `description` at most 2048 bytes; `buzz-channel` at most 256 bytes; `buzz-visibility` at most 256 bytes. The two `buzz-` bounds are generous by design: neither value has a semantic length, and the bound exists only so an unbounded string cannot ride into storage on a tag ingest does not interpret. + +Rules 3 through 6 are evaluated in that order, so an oversized tag list is refused on count before any per-tag parse or set proportional to it is built. + +Three checks land in the Buzz validator together with the fixture wiring that exercises them: the `buzz-channel` and `buzz-visibility` bounds in rule 8, and rule 4's arity. The validator bounds `name` and `description` today, and reads element 1 of each member `a` tag while ignoring any element past it. + +**Duplicates are rejected, never normalized.** A relay cannot dedupe tags inside a signed event: rewriting the tag array changes the event id and invalidates the signature. The choices are reject, or accept and require every present and future consumer to apply a first-wins interpretation rule. Rejecting keeps every stored head canonical and spares all consumers a defensive parse. + +**No membership authorization.** The relay MUST NOT check whether the signer owns, maintains, or has any relationship to a member repository. Referencing another owner's repository is legal and is the point of the kind. Because membership grants nothing ([Authority](#authority)), there is nothing to authorize. + +**Routing.** `kind:30621` is global-only, like every other NIP-34 kind in Buzz: it is addressed by `(pubkey, kind, d)` and is never channel-scoped. A stray `h` tag MUST NOT scope it to a channel — the `buzz-channel` tag is a metadata reference, not a routing directive. + +**Scope.** Writes require the `repos:write` scope, matching `kind:30617` and `kind:30618`. A project is repository metadata; a client authorized to announce repositories is authorized to group them. + +**Replacement** follows NIP-01 with no special cases: newest `created_at` wins per `(pubkey, 30621, d)`, and one pubkey can never overwrite another's coordinate. + +**Deletion** follows NIP-09 with two Buzz-wide behaviors that are not project-specific: + +- A `kind:5` naming the coordinate deletes it when signed by the project signer **or** by that signer's registered NIP-OA owner ([Deletion](#deletion)). +- The deletion applies only to versions whose `created_at` is at or before the deletion's own, per NIP-09. A delayed or replayed tombstone signed before the current head MUST NOT remove it; the relay MUST compare timestamps at the coordinate (`soft_delete_by_coordinate`, `crates/buzz-db/src/event.rs`, whose inclusive `created_at <= ` bound is introduced alongside this specification in [#3171](https://github.com/block/buzz/pull/3171)). + +## Client Behavior + +### Listing eligibility + +A project is **listing eligible** for a client when that client is currently rendering it in its project collection. A project is not listing eligible when: + +- its `buzz-visibility` is `unlisted`, or +- the viewer has hidden it locally, or +- it has been deleted, or its latest head is otherwise not being rendered. + +Only listing-eligible projects claim members. This keeps visibility deterministic in the case that otherwise breaks: an unlisted project must not make a repository the viewer can plainly see disappear from the collection, because the container that claims it is not on screen to hold it. + +### Claim authority + +A project **claims** a member — suppressing that repository's implicit card, per step 3 of the fold — only when the project is listing eligible *and* its signer is authorized by the member repository itself: the signer is the repository's owner (the pubkey in the member coordinate), or is listed in a `maintainers` tag on the repository's own live `kind:30617`. + +Authority is therefore read from the member repository's *content*, not merely its existence: a client that has resolved only a coordinate, and not the head it names, cannot yet decide whether a project claims it. `maintainers` is the standard NIP-34 multi-value tag; Buzz's own announcement builder does not emit it today, so in practice every current claim reduces to signer-is-owner, and the `maintainers` clause is what keeps a co-maintained repository working the day that changes. + +Without this rule, membership would carry exactly the authority [Authority](#authority) says it does not. Anyone may publish a project naming anyone's repository, so an unauthorized project that suppressed implicit cards would let a stranger pull someone else's repository out of the collection and into a container the owner never consented to — a signed assertion silently becoming control over another owner's discovery surface. + +An unauthorized project still renders, and still renders its members inside itself: cross-owner grouping works, which is the entire point of the kind. What it cannot do is *remove* a repository from where its owner expects to find it. The visible consequence is that a repository in a stranger's project renders in both places — inside that project and as its own card — which is the correct reading of an unendorsed grouping claim. + +### The fold + +Given the set of repositories and projects to render, a client MUST derive the collection as follows. + +1. **Enumerate exhaustively when possible.** Retrieve the latest live head of every `kind:30621` and `kind:30617` coordinate, plus the `kind:5` deletions bearing on them, using paginated queries. A fixed `limit` MUST NOT be used: with a limit of 200, repository 201 vanishes from the collection, which is precisely the compatibility guarantee this NIP owes existing repositories. What "to exhaustion" means depends on the cursor the relay offers — see [Pagination](#pagination). On a relay that does not provide an exhaustive mode, a client MUST mark the collection possibly incomplete rather than present a partial result as complete. +2. **Resolve members.** For each project, resolve each member coordinate to its repository head, and determine whether the project [claims](#claim-authority) each one. +3. **Suppress claimed implicit cards.** A live repository claimed by at least one project does not also render as an implicit single-repository card. +4. **Render multiple membership.** A repository belonging to several listing-eligible projects renders inside each of them, claimed or not. +5. **Fall back.** A repository claimed by no project renders as an implicit single-repository card — including when an unauthorized project also renders it as a member. +6. **Mark unresolvable members.** A member coordinate that resolves to nothing — never announced, deleted, or not present on this relay — renders inside its project as explicitly unavailable. It MUST NOT become a phantom standalone card, and it MUST NOT be silently dropped: silence makes a project look smaller than its author declared. +7. **Hiding a container never hides repositories.** Locally hiding a project makes it not listing eligible, so it claims nothing and by step 5 its members return as implicit cards. Hiding a grouping is a statement about the grouping. A repository disappears from the collection only when the viewer hides that repository or it is deleted — and a repository the viewer has hidden is hidden everywhere, including inside every project that lists it, so hiding one cannot be undone by someone else's grouping. + +The fold is deterministic: same heads in, same collection out, independent of arrival order or query shape. **Placement, not order, is what the fold fixes** — the collection of containers, the members rendered inside each container, and the implicit cards are all compared as sets, since member order is not significant in the event ([Event Format](#event-format)) and a client is free to sort its own presentation. Every live, unhidden repository renders in at least one place — inside a project that claims it, or as its own card — and no repository renders twice within one container. + +### Required fold cases + +The fold cannot be expressed as accept/reject of a single event, so it has its own fixture file rather than living in the ingest [conformance fixtures](#conformance-fixtures). A client implementing the fold MUST cover at least these cases, each of which is a distinct branch above: + +| Case | Expected collection | +|------|---------------------| +| Owner's own project lists their repository | Repository renders inside the project only | +| Stranger's project lists someone else's repository | Repository renders inside that project *and* as its own card | +| Project signer is in the member repository's `maintainers` tag | Repository renders inside the project only | +| Repository is a member of two projects that both claim it | Repository renders inside both; no implicit card | +| Repository removed from every project | Repository renders as an implicit card | +| Project is `unlisted`, or locally hidden | Project absent from the collection; its members render as implicit cards | +| Viewer has hidden a member repository | Repository absent from the collection *and* from inside every project listing it | +| Member coordinate resolves to nothing | Member renders inside its project as unavailable; no standalone card | +| Project head deleted | Project absent; its members render as implicit cards | +| One authorized and one unauthorized project both list the same repository | Repository renders inside both projects; no implicit card, because one claim suffices to suppress it | +| More repositories and projects than one page holds, with several sharing one `created_at` | Every repository and project renders | + +[`NIP-MP.fold-fixtures.json`](NIP-MP.fold-fixtures.json) mechanizes this table — see [Conformance Fixtures](#conformance-fixtures). + +### Pagination + +Step 1's "to exhaustion" describes the target result, not a single algorithm: what a client must do — and whether it can fully reach it — depends on the cursor its relay offers. Both modes below are conformant; a client MUST implement whichever its relay supports, MUST NOT present a mode-1 loop's output as complete on a mode-2 relay, and on a relay that provides neither mode 1 nor the relay contract below, MUST mark the collection possibly incomplete — presenting that marked partial collection is conformant, not a violation of step 1's enumeration requirement. + +**The relay contract both modes rest on.** Every "short response = done" inference — whether from a composite cursor or a drained bucket — is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. Exhaustive enumeration is possible only on a relay that satisfies **all three** of these conditions, which a client can evaluate independently: + +1. The relay **applies the complete filter before enforcing any limit.** A relay that post-filters after limiting can return a short (even empty) response while older matching events sit beyond the limited window, so short responses carry no exhaustion signal on such a relay. +2. The relay **exposes the exact effective page limit it enforces.** The effective page limit is the smaller of the requested `limit` and any relay-imposed cap, since a clamped request answered in full is short without being exhausted. If the advertised cap differs from the enforced one, "shorter than the effective limit" is undecidable by the client. +3. The relay **saturates pages**: after applying the complete filter and cursor, it returns `min(effective page limit, remaining matching events)` events — equivalently, whenever at least the effective limit's worth of matches remain, the page is full, so a short page contains all remaining matches. A cap bounds from above; without saturation, a relay may return fewer than the cap even when matches are still available, and a short page proves nothing. An authoritative relay-provided continuation or end signal computed after complete filtering is an equivalent substitute for this response-length inference. + +A relay satisfying any proper subset of these conditions does not provide the guarantee. Absent the guarantee, a client MUST mark the collection possibly incomplete regardless of any response sizes; the modes below serve to reduce silent loss rather than eliminate it. `limit` below means the effective page limit. + +**Mode 1 — composite cursor (exhaustive under the relay contract).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. As an example of the cursor mechanics, Buzz implements the keyset as `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`), resolving the sort to `(created_at DESC, id ASC)`. Buzz exposes this cursor on its authenticated HTTP bridge endpoint (`crates/buzz-relay/src/api/bridge.rs`); it is not available on the NIP-01 websocket REQ path, where `before_id` is silently discarded — `protocol.rs` deserializes each REQ filter into a standard `nostr::Filter`, whose deserializer drops unknown fields, so a client sending `before_id` on a REQ receives no error and falls back to `until`-only paging without knowing it. A NIP-01 websocket client reading `kind:30621` from Buzz is therefore in mode 2, not mode 1; mode selection requires evaluating the relay contract per transport. Within the relay contract, the uniqueness of the `(created_at, id)` pair means each page resumes exactly where the last ended with no skips or re-reads, and a short page is an unambiguous end signal. Cursor uniqueness adds tie-safety; it does not substitute for the relay contract — a relay that post-filters after limiting can return an empty page under this cursor while older matching events remain beyond the candidate window. + +**Mode 2 — `until` only (boundary-bucket drain; exhaustive only under the relay contract).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it. + +1. A page returning fewer than `limit` events means the query is exhausted — stop. +2. After a **full** page, let `oldest` be the smallest `created_at` it returned. Query that second exactly — `since = until = oldest` — and merge the result into what is already held, deduplicating by event id. That single bucket query has two outcomes. +3. If it returns `limit` events, second `oldest` may hold more than the relay will return in one response, so the collection MUST be marked possibly incomplete. Count `limit` inclusively: a bucket holding exactly `limit` events is indistinguishable from a larger one, and over-reporting a doubt is the safe direction. +4. If instead it returns fewer than `limit` events, the second is fully drained. Set `until = oldest - 1` and continue from step 1. + +A client that cannot drain a bucket has lost exhaustiveness for that second and MUST keep the collection marked possibly incomplete; it MAY still set `until = oldest - 1` to gather the older events rather than stall, but MUST NOT clear the mark by doing so. + +The naive form fails on a page whose oldest second is only partly returned, which a same-`created_at` test on the page as a whole does not see. With `limit = 3` over `(100,a) (99,b) (99,c) (99,d) (98,e)`, the first page is `(100,a) (99,b) (99,c)` — two distinct timestamps, so no all-tied heuristic fires — and advancing to `until = 98` silently drops `(99,d)`. Draining second `99` first retrieves it. + +Enumeration is therefore exhaustive when the relay satisfies the contract above and every equal-`created_at` bucket fits in one response; under those conditions truncation is detected exactly rather than guessed at. On detecting it — or on any relay that does not meet the contract — a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced. + +**Query shapes.** The relay contract applies only where the relay can apply it — and that depends on the query shape. A relay that post-filters some constraints (such as `#a` tag matching applied after the SQL `LIMIT`) cannot guarantee short-response exhaustion for queries that use those constraints. A client MUST therefore issue fold queries in shapes whose full filter the relay applies before limiting. Where a needed constraint is not applied pre-limit on the target relay, the client MUST widen the query to constraints that are — for example, enumerating all `kind:5` events by `kinds` alone, or `kinds` + `authors`, rather than adding an `#a` filter the relay post-applies — and match the remaining criteria client-side. This keeps the relay contract's short-response guarantee intact for every query the fold issues. + +### Collection growth + +Step 1's exhaustive enumeration is a correctness floor, not a scaling strategy: it says a client MUST NOT silently truncate its collection, because a repository absent from the list is indistinguishable from one that does not exist. It is not a mandate to hold the relay's entire repository set in memory on every load. + +At Buzz's current scale (hundreds of repositories per community) exhaustive enumeration is the whole story. Past that, the way out is a narrower question — a server-side collection query, a scoped or searched subset, or resolving a project's members on demand — not a fixed client-side `limit`. Any such surface MUST report its own truncation so a client can say "showing N of M" rather than quietly presenting a partial collection as complete. + +### Route resolution + +A project route resolves to a container; a repository route resolves to a repository. Every repository-scoped operation — clone, fetch, issues, pull requests, activity, mutation, deletion — MUST take an explicit repository coordinate. None may infer its target from container state, or a two-repository project will silently operate on the wrong member. + +Legacy `:` repository routes remain valid and resolve to that repository, presented as a single-repository container. + +## Conformance Fixtures + +Two fixture files carry the machine-checkable contract. Neither has consumers yet; each states what its consumers are required to do. + +### Ingest + +[`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) holds the shared valid/invalid case set: 11 accepted and 20 rejected events covering minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-`d`-different-owner members, colon-bearing repository `d` values, relay hints, non-empty `content`, and each rejection rule above. + +The relay validator, the Rust builder, and the TypeScript builder are required to test against this one file, so a divergence between them is a test failure rather than a production surprise. + +Each case carries an **unsigned** template — `kind`, `content`, `tags`. Consumers sign it with their own test key. Signed literals would be inert: the id and signature are fixed by the exact serialization, so any consumer that re-serializes would need to recompute both anyway. Rejection cases name their `reject_rules`, so an implementation cannot pass by rejecting a bad event for an unrelated reason. + +### Fold + +[`NIP-MP.fold-fixtures.json`](NIP-MP.fold-fixtures.json) holds the oracle for [the fold](#the-fold): 12 cases covering every row of the [required fold cases](#required-fold-cases) table. Every client implementing the fold is required to test against this one file. The fold is where the [claim authority](#claim-authority) rule lives, so without a shared oracle two clients could each satisfy the prose and still render different collections from identical heads. + +Its cases are **semantic, not signed envelopes**. A repository or project is named by its coordinate plus the inputs the fold actually reads — signer, members, `maintainers`, visibility, viewer-hidden, deletion. Signing would test the ingest contract a second time and obscure what is under test: this file assumes every input is an already-accepted head and pins only the placement derived from it. Each case gives `expect.containers` (each rendered project with the members rendered inside it) and `expect.implicit_cards` (the repositories that additionally render as their own cards). Every collection in `expect` is compared as a set — the containers, each container's `members`, and the implicit cards alike — because the fold fixes placement and not order. + +## Security Considerations + +**Unauthorized grouping claims are the accepted trade.** Anyone may publish a project referencing anyone's repositories. That claim is a signed statement attributable to its author and grants nothing ([Authority](#authority)) — the same trust model as NIP-51 lists, which likewise reference content their author does not own. A client MUST NOT present membership in a stranger's project as endorsement by, or authority over, the member repository's owner, and MUST show the project signer alongside a project it did not author. + +**Resolution fan-out is bounded.** Each project resolves at most 64 coordinates, and the cap counts raw tags, so no single event can force unbounded resolution work regardless of how its tag list is shaped. + +**Push policy is untouched.** A project cannot grant, widen, or narrow push access to any repository. Push policy reads only the repository's own `kind:30617`. This is a design invariant, not an implementation detail: if a project ever became an input to push authorization, publishing a project naming someone else's repository would become a privilege-escalation primitive. + +## Relation to Other NIPs + +- **NIP-34**: Supplies the member repositories. Members are `kind:30617` announcements referenced by coordinate; a NIP-34 client that does not know `kind:30621` still discovers and renders each repository normally. +- **NIP-01**: Supplies the addressable-event class, the `a` tag grammar, addressing, replacement, and the owner-only editing model. Owner-only editing is not enforcement code in Buzz — it is what NIP-01 replacement already means. +- **NIP-09**: Supplies container deletion, which deletes the container only. Buzz extends it in two ways that are not project-specific: an agent's registered NIP-OA owner may also delete, and a tombstone applies only at or before its own `created_at` ([Deletion](#deletion)). +- **NIP-29**: Supplies the channel a project's `buzz-channel` names. The reference is metadata; project state is never channel-scoped. +- **NIP-51**: The closest existing precedent — a signed, addressable list referencing content the author need not own. Not reused because a project is a shared named forge container with its own channel binding and visibility, not a user's private-or-public bookmark set. +- **NIP-OA**: Consulted for container deletion only — an agent's registered owner may delete the agent's project ([Deletion](#deletion)). Push access is unaffected: agents inherit repository push access from their owner through the repository's own protections, and a project is never consulted. diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index db3985048..aecef6329 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; @@ -87,8 +89,16 @@ class ActivityPage extends HookConsumerWidget { ]; // Preload sender profiles for visible rows. - final pubkeys = visibleItems.map((i) => i.item.pubkey).toSet().toList(); - ref.read(userCacheProvider.notifier).preload(pubkeys); + final preloadPubkeys = { + for (final item in visibleItems) item.item.pubkey.toLowerCase(), + for (final item in visibleItems) + ...mentionedPubkeysFromTags(item.item.tags), + }.toList()..sort(); + final preloadPubkeysKey = preloadPubkeys.join('\u0000'); + useEffect(() { + ref.read(userCacheProvider.notifier).preload(preloadPubkeys); + return null; + }, [preloadPubkeysKey]); final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length; diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index fb267c03b..9dd8b6ddf 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -61,6 +61,28 @@ class _InboxRow extends ConsumerWidget { final userCache = ref.watch(userCacheProvider); final profile = userCache[item.item.pubkey.toLowerCase()]; final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey); + final profileMentionNames = { + for (final pubkey in mentionedPubkeysFromTags(item.item.tags)) + if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: userCache[pubkey]!.displayName!.trim(), + }; + final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags); + final knownAgentPubkeys = channel == null + ? ref.watch(knownAgentPubkeysProvider) + : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: knownAgentPubkeys, + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); final isDm = channel?.isDm ?? false; final channelName = channel != null && !isDm @@ -172,6 +194,8 @@ class _InboxRow extends ConsumerWidget { // Message preview. MessageContent( content: item.item.displayContent, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: item.item.tags, maxLines: 2, baseStyle: activityPreviewTextStyle.copyWith( diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 179ea0d4f..8730908ad 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -8,21 +8,20 @@ import '../channel_typing_provider.dart'; /// /// Used by both the members button badge and the members sheet to avoid /// duplicating the bot-typing cross-reference logic. -final workingBotPubkeysProvider = Provider.family, String>(( - ref, - channelId, -) { - final typingEntries = ref.watch(channelTypingProvider(channelId)); - final membersAsync = ref.watch(channelMembersProvider(channelId)); - final allMembers = membersAsync.asData?.value ?? const []; +final workingBotPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final typingEntries = ref.watch(channelTypingProvider(channelId)); + final membersAsync = ref.watch(channelMembersProvider(channelId)); + final allMembers = membersAsync.asData?.value ?? const []; - final botPubkeys = { - for (final m in allMembers) - if (m.isBot) m.pubkey.toLowerCase(), - }; + final botPubkeys = { + for (final m in allMembers) + if (m.isBot) m.pubkey.toLowerCase(), + }; - return { - for (final e in typingEntries) - if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(), - }; -}); + return { + for (final e in typingEntries) + if (botPubkeys.contains(e.pubkey.toLowerCase())) + e.pubkey.toLowerCase(), + }; + }); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 7abeed8d7..044342d10 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -39,7 +40,6 @@ import 'manage_channel_sheet.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; -import 'mentions/mention_candidates_provider.dart'; import 'read_state/deferred_read_state_update.dart'; import 'read_state/read_state_provider.dart'; import 'read_state/read_state_time.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index fcabfd619..cb6ab6706 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -36,8 +36,14 @@ class _MessageBubble extends ConsumerWidget { // Build mention names map from event p-tags. final userCache = ref.watch(userCacheProvider); - final knownAgentPubkeys = ref.watch( - mentionAgentPubkeysProvider(currentChannelId), + final knownAgentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch( + agentMentionPubkeysProvider(currentChannelId), + ), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], ); final mentionNames = {}; final agentMentionPubkeys = {}; @@ -51,6 +57,12 @@ class _MessageBubble extends ConsumerWidget { agentMentionPubkeys.add(normalizedPubkey); } } + final resolvedMentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: message.mentionPubkeys, + profileMentionNames: mentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), @@ -166,7 +178,7 @@ class _MessageBubble extends ConsumerWidget { ), MessageContent( content: message.content, - mentionNames: mentionNames, + mentionNames: resolvedMentionNames, agentMentionPubkeys: agentMentionPubkeys, channelNames: channelNames, tags: message.tags, diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 9a72054a2..b990194d1 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/auth/auth.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../profile/profile_provider.dart'; import 'channel.dart'; @@ -392,8 +393,9 @@ final channelDetailsProvider = FutureProvider.family(( }); /// Channel members from kind:39002 NIP-29 members event. -final channelMembersProvider = - FutureProvider.family, String>((ref, channelId) async { +final channelMembersProvider = FutureProvider.autoDispose + .family, String>((ref, channelId) async { + ref.watch(channelMembershipUpdateProvider(channelId)); final session = ref.watch(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.channelMembers(channelId), @@ -557,6 +559,7 @@ class ChannelActions { ); } _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future joinChannel(String channelId) async { @@ -626,6 +629,7 @@ class ChannelActions { await _ref.read(channelsProvider.notifier).refresh(); _ref.invalidate(channelDetailsProvider(channelId)); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); _ref.invalidate(channelCanvasProvider(channelId)); } @@ -659,6 +663,7 @@ class ChannelActions { ], ); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future removeMember({ @@ -674,6 +679,7 @@ class ChannelActions { ], ); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future addReaction(String eventId, String emoji) async { diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index c03087a85..fbcbca895 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; -import 'channel_management_provider.dart'; import 'pending_local_messages_provider.dart'; import 'channel_window.dart'; import 'thread_replies_provider.dart'; @@ -222,11 +221,6 @@ class ChannelMessagesNotifier extends Notifier>> { _lastKnownMessages = merged; state = AsyncData(merged); } - - if (event.kind == EventKind.systemMessage && - _isMembershipEvent(event.content)) { - ref.invalidate(channelMembersProvider(channelId)); - } } void _handleWindowLiveEvent(NostrEvent event) { @@ -288,12 +282,6 @@ class ChannelMessagesNotifier extends Notifier>> { .confirm(eventIds); } - static bool _isMembershipEvent(String content) { - return content.contains('member_joined') || - content.contains('member_left') || - content.contains('member_removed'); - } - /// Adds a just-signed outgoing message before the relay acknowledges it. /// The live relay echo is deduplicated by event id. void addLocalMessage(NostrEvent event) { diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 1a9a02e40..7560f998f 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -15,6 +15,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -36,6 +37,7 @@ import 'mentions/mention_ranking.dart'; import 'photo_library.dart'; part 'compose_bar/helpers.dart'; +part 'compose_bar/agent_mention_labels.dart'; part 'compose_bar/markdown_editing_controller.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; @@ -231,6 +233,16 @@ class ComposeBar extends HookConsumerWidget { // owners so @mention suggestions show names ("managed by …" included). final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; final agentOwners = ref.watch(agentOwnersProvider).asData?.value; + final agentMentionLabels = _agentMentionLabels( + candidates: mentionMap.value.values, + ); + final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join( + '\u0000', + ); + useEffect(() { + controller.setAgentMentionNames(agentMentionLabels); + return null; + }, [controller, agentMentionLabelsKey]); useEffect( () { final memberList = membersAsync.asData?.value ?? []; diff --git a/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart new file mode 100644 index 000000000..bc29d5bb4 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart @@ -0,0 +1,10 @@ +part of '../compose_bar.dart'; + +Set _agentMentionLabels({ + required Iterable candidates, +}) { + return { + for (final candidate in candidates) + if (candidate.isAgent) candidate.label, + }; +} diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart index 29e22a8ca..bbca57037 100644 --- a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -15,6 +15,8 @@ class _MarkdownRule { } class _MarkdownEditingController extends TextEditingController { + final Set _agentMentionNames = {}; + static final _rules = [ _MarkdownRule( r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```', @@ -31,6 +33,21 @@ class _MarkdownEditingController extends TextEditingController { _MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic), ]; + /// Updates the known agent labels which should render as agent mention + /// chips. The editor still stores the literal `@Name` text, matching the + /// markdown sent to the relay. + void setAgentMentionNames(Iterable names) { + final next = { + for (final name in names) + if (name.trim().isNotEmpty) name.trim().toLowerCase(), + }; + if (setEquals(_agentMentionNames, next)) return; + _agentMentionNames + ..clear() + ..addAll(next); + notifyListeners(); + } + @override TextSpan buildTextSpan({ required BuildContext context, @@ -81,6 +98,7 @@ class _MarkdownEditingController extends TextEditingController { if (nextRule == null || nextMatch == null) { spans.addAll( _buildTextSpans( + context, source.substring(offset), inheritedStyle, sourceOffset + offset, @@ -93,6 +111,7 @@ class _MarkdownEditingController extends TextEditingController { if (nextMatch.start > 0) { spans.addAll( _buildTextSpans( + context, tail.substring(0, nextMatch.start), inheritedStyle, sourceOffset + offset, @@ -129,10 +148,12 @@ class _MarkdownEditingController extends TextEditingController { } else { spans.addAll( _buildTextSpans( + context, content, contentStyle, matchOffset + contentStart, composingRange, + renderAgentMentions: false, ), ); } @@ -150,11 +171,18 @@ class _MarkdownEditingController extends TextEditingController { } List _buildTextSpans( + BuildContext context, String source, TextStyle style, int sourceOffset, - TextRange composingRange, - ) { + TextRange composingRange, { + bool renderAgentMentions = true, + }) { + List buildTextSegment(String text, TextStyle segmentStyle) => + renderAgentMentions + ? _buildAgentMentionSpans(context, text, segmentStyle) + : [TextSpan(text: text, style: segmentStyle)]; + if (source.isEmpty) return const []; final localStart = (composingRange.start - sourceOffset) .clamp(0, source.length) @@ -165,7 +193,7 @@ class _MarkdownEditingController extends TextEditingController { if (!composingRange.isValid || composingRange.isCollapsed || localStart >= localEnd) { - return [TextSpan(text: source, style: style)]; + return buildTextSegment(source, style); } final composingDecorations = [ @@ -178,16 +206,80 @@ class _MarkdownEditingController extends TextEditingController { ); return [ if (localStart > 0) - TextSpan(text: source.substring(0, localStart), style: style), + ...buildTextSegment(source.substring(0, localStart), style), TextSpan( text: source.substring(localStart, localEnd), style: composingStyle, ), if (localEnd < source.length) - TextSpan(text: source.substring(localEnd), style: style), + ...buildTextSegment(source.substring(localEnd), style), ]; } + List _buildAgentMentionSpans( + BuildContext context, + String source, + TextStyle style, + ) { + if (_agentMentionNames.isEmpty) { + return [TextSpan(text: source, style: style)]; + } + + final escapedNames = _agentMentionNames.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + final expression = RegExp( + r'(^|\s)@(' + + escapedNames.map(RegExp.escape).join('|') + + r')(?=\s|[,.!?:;)\]}*_]|$)', + caseSensitive: false, + multiLine: true, + ); + final spans = []; + var offset = 0; + for (final match in expression.allMatches(source)) { + final prefix = match.group(1)!; + if (match.start > offset) { + spans.add( + TextSpan(text: source.substring(offset, match.start), style: style), + ); + } + if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style)); + + final label = match.group(2)!; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _ComposerAgentMentionChip(label: label, textStyle: style), + ), + ); + // The visual chip replaces the `@` placeholder. Keep the label as + // invisible source text so the text span still has one character per + // source character, preserving native cursor and deletion behavior. + spans.add( + TextSpan( + text: label, + semanticsLabel: '', + style: _hiddenMentionTextStyle(style), + ), + ); + offset = match.end; + } + if (offset < source.length) { + spans.add(TextSpan(text: source.substring(offset), style: style)); + } + return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans; + } + + TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) => + inheritedStyle.copyWith( + color: Colors.transparent, + fontSize: 0.01, + height: 0.01, + letterSpacing: 0, + decoration: TextDecoration.none, + ); + (int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) { final delimiterLength = switch (markdownStyle) { _MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2, @@ -247,3 +339,60 @@ class _MarkdownEditingController extends TextEditingController { ); } } + +class _ComposerAgentMentionChip extends StatelessWidget { + final String label; + final TextStyle textStyle; + + const _ComposerAgentMentionChip({ + required this.label, + required this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final style = textStyle.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w500, + height: 1, + ); + final fontSize = style.fontSize ?? 16; + + return Semantics( + label: 'Agent mention: $label', + excludeSemantics: true, + child: Container( + key: const ValueKey('composer-agent-mention-chip'), + padding: const EdgeInsets.fromLTRB( + Grid.half, + Grid.quarter + 1, + Grid.half, + Grid.quarter, + ), + decoration: BoxDecoration( + // The composer surface is already tinted, so the body chip's + // low-opacity fill disappears here. Keep the same chip geometry + // while giving this editable token enough contrast to read as one. + color: context.colors.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: context.colors.primary.withValues(alpha: 0.12), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + LucideIcons.bot, + size: fontSize * 0.95, + color: context.colors.primary, + ), + const SizedBox(width: Grid.quarter), + Text(label, style: style), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index ca97d65e3..9c4ef96bb 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -1,59 +1,8 @@ -import 'dart:convert'; - -import '../../../shared/relay/nostr_models.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../profile/user_profile.dart'; import '../channel_management_provider.dart'; import 'mention_ranking.dart'; -/// A relay agent parsed from its kind:10100 agent-profile event. -/// -/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility -/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which -/// channels it sits in. -class AgentDirectoryEntry { - final String pubkey; - final String? displayName; - final String? respondTo; - final List respondToAllowlist; - final List channelIds; - - const AgentDirectoryEntry({ - required this.pubkey, - this.displayName, - this.respondTo, - this.respondToAllowlist = const [], - this.channelIds = const [], - }); - - factory AgentDirectoryEntry.fromEvent(NostrEvent event) { - final content = _tryDecodeJsonMap(event.content); - return AgentDirectoryEntry( - pubkey: event.pubkey.toLowerCase(), - displayName: - (content?['display_name'] as String?) ?? - (content?['name'] as String?), - respondTo: content?['respond_to'] as String?, - respondToAllowlist: [ - for (final value in (content?['respond_to_allowlist'] as List?) ?? []) - if (value is String) value.toLowerCase(), - ], - channelIds: [ - for (final value in (content?['channel_ids'] as List?) ?? []) - if (value is String) value, - ], - ); - } -} - -Map? _tryDecodeJsonMap(String content) { - try { - final decoded = jsonDecode(content); - return decoded is Map ? decoded : null; - } catch (_) { - return null; - } -} - /// Whether a non-member relay agent should be mentionable by the current /// user. Mirrors desktop's `relayAgentIsSharedWithUser`: /// - allowlist mode: user must be on the allowlist diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index c2aa056a0..6e9445923 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/crypto/nip_oa.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/relay/relay.dart'; import '../../profile/user_cache_provider.dart'; import '../../profile/user_profile.dart'; @@ -10,64 +11,6 @@ import '../channels_provider.dart'; import 'mention_candidates.dart'; import 'mention_ranking.dart'; -/// Relay agent directory from kind:10100 agent-profile events. -/// -/// Watches the session and only fetches after the WebSocket connects. -final agentDirectoryProvider = FutureProvider>(( - ref, -) async { - final sessionState = ref.watch(relaySessionProvider); - if (sessionState.status != SessionStatus.connected) return const []; - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory(NostrFilters.agentProfiles()); - return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; -}); - -/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 -/// profiles. An entry exists only when the `auth` tag verifies — mirrors -/// desktop's `profile_valid_oa_owner_pubkey`. -final agentOwnersProvider = FutureProvider>((ref) async { - final agents = await ref.watch(agentDirectoryProvider.future); - if (agents.isEmpty) return const {}; - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), - ); - final owners = {}; - for (final event in events) { - final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); - if (owner != null) owners[event.pubkey.toLowerCase()] = owner; - } - return owners; -}); - -/// Pubkeys currently known to represent agents for rendered mention chips. -/// -/// Uses the same three identity sources as mention autocomplete: channel bot -/// roles, relay agent-directory entries, and verified NIP-OA ownership. -final mentionAgentPubkeysProvider = Provider.family, String>(( - ref, - channelId, -) { - final members = - ref.watch(channelMembersProvider(channelId)).asData?.value ?? - const []; - final relayAgents = - ref.watch(agentDirectoryProvider).asData?.value ?? - const []; - final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; - final userCache = ref.watch(userCacheProvider); - - return { - for (final member in members) - if (member.isBot) member.pubkey.toLowerCase(), - for (final agent in relayAgents) agent.pubkey.toLowerCase(), - ...owners.keys.map((pubkey) => pubkey.toLowerCase()), - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey.toLowerCase(), - }; -}); - /// Debounce before a mention query hits the relay search endpoint. const _mentionSearchDebounce = Duration(milliseconds: 250); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index ab992a431..465585d96 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -145,6 +145,10 @@ class MessageContent extends HookConsumerWidget { final baseTextStyle = baseStyle ?? context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); + final resolvedMentionNames = mentionNames; + final resolvedAgentMentionPubkeys = { + ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), + }; final imetaByUrl = parseImetaTags(tags); final trailingGallery = maxLines == null ? _extractTrailingImageGallery(content, imetaByUrl) @@ -154,6 +158,13 @@ class MessageContent extends HookConsumerWidget { customEmojiFromTags(tags), ref.watch(customEmojiListProvider), ); + final mentionPresentationKey = [ + for (final entry + in (resolvedMentionNames.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)))) + '${entry.key}\u0000${entry.value}', + ...(resolvedAgentMentionPubkeys.toList()..sort()), + ].join('\u0001'); // Decided here rather than by the caller: this is where the event's own // emoji tags and the community palette have already been merged, and a @@ -220,7 +231,7 @@ class MessageContent extends HookConsumerWidget { mentionBuf.write('`${mentionParts[i]}`'); } else { var segment = mentionParts[i]; - for (final name in mentionNames.values) { + for (final name in resolvedMentionNames.values) { if (name.contains(' ')) { final normalizedName = _markdownMentionName(name); segment = segment.replaceAllMapped( @@ -241,29 +252,35 @@ class MessageContent extends HookConsumerWidget { result = '\u200B$result'; } return result; - }, [markdownContent, mentionNames]); + }, [markdownContent, resolvedMentionNames]); - final markdown = GptMarkdown( - finalContent, - style: style, - followLinkColor: false, - codeBuilder: (context, name, code, closed) => - _MessageCodeBlock(name: name, code: code), - linkBuilder: (context, linkText, url, linkStyle) => - _buildLink(context, ref, linkText, url, linkStyle, style), - imageBuilder: (context, imageUrl) => - _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), - maxLines: maxLines, - inlineComponents: [ - _MentionMd( - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - onMentionTap: onMentionTap, - ), - CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), - _ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap), - ...MarkdownComponent.inlineComponents, - ], + final markdown = KeyedSubtree( + key: ValueKey('$finalContent\u0000$mentionPresentationKey'), + child: GptMarkdown( + finalContent, + style: style, + followLinkColor: false, + codeBuilder: (context, name, code, closed) => + _MessageCodeBlock(name: name, code: code), + linkBuilder: (context, linkText, url, linkStyle) => + _buildLink(context, ref, linkText, url, linkStyle, style), + imageBuilder: (context, imageUrl) => + _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), + maxLines: maxLines, + inlineComponents: [ + _MentionMd( + mentionNames: resolvedMentionNames, + agentMentionPubkeys: resolvedAgentMentionPubkeys, + onMentionTap: onMentionTap, + ), + CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), + _ChannelLinkMd( + channelNames: channelNames, + onChannelTap: onChannelTap, + ), + ...MarkdownComponent.inlineComponents, + ], + ), ); if (trailingGallery == null) return markdown; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 5d28214b0..810861aa0 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -24,7 +25,6 @@ import 'day_divider.dart'; import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; -import 'mentions/mention_candidates_provider.dart'; import 'reaction_row.dart'; import 'read_state/read_state_format.dart'; import 'read_state/read_state_provider.dart'; @@ -607,7 +607,13 @@ class _ThreadMessage extends ConsumerWidget { profile?.ownerPubkey == currentPubkey?.toLowerCase()); final userCache = ref.watch(userCacheProvider); - final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId)); + final knownAgentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); final mentionNames = {}; final agentMentionPubkeys = {}; for (final mpk in message.mentionPubkeys) { @@ -620,6 +626,12 @@ class _ThreadMessage extends ConsumerWidget { agentMentionPubkeys.add(normalizedPubkey); } } + final resolvedMentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: message.mentionPubkeys, + profileMentionNames: mentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), @@ -725,7 +737,7 @@ class _ThreadMessage extends ConsumerWidget { ), MessageContent( content: message.content, - mentionNames: mentionNames, + mentionNames: resolvedMentionNames, agentMentionPubkeys: agentMentionPubkeys, channelNames: channelNames, tags: message.tags, diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 8666919a4..ddc36a1d4 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../channels/message_content.dart'; @@ -15,7 +17,7 @@ import 'forum_models.dart'; /// /// Long-press opens an action sheet (copy, delete) matching the stream /// message pattern from channel_detail_page.dart. -class ForumPostCard extends ConsumerWidget { +class ForumPostCard extends HookConsumerWidget { final ForumPost post; final String? currentPubkey; final VoidCallback onTap; @@ -31,16 +33,57 @@ class ForumPostCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mentionPubkeys = useMemoized( + () => + post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()).toSet() + ..remove(post.pubkey.toLowerCase()), + [post], + ); + final mentionPubkeysKey = (mentionPubkeys.toList()..sort()).join('\u0000'); + + useEffect(() { + if (mentionPubkeys.isNotEmpty) { + ref.read(userCacheProvider.notifier).preload(mentionPubkeys.toList()); + } + return null; + }, [mentionPubkeysKey]); + final pk = post.pubkey.toLowerCase(); final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? _shortPubkey(post.pubkey); - final mentionNames = ref.watch( + final profileMentionNames = ref.watch( userCacheProvider.select( (cache) => _buildMentionNames(post.mentionPubkeys, cache), ), ); + final profileOwnedMentionPubkeys = ref.watch( + userCacheProvider.select( + (cache) => + (post.mentionPubkeys + .where( + (pubkey) => + cache[pubkey.toLowerCase()]?.ownerPubkey != null, + ) + .map((pubkey) => pubkey.toLowerCase()) + .toList() + ..sort()) + .join('\u0000'), + ), + ); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)), + profileOwnedAgentPubkeys: profileOwnedMentionPubkeys.isEmpty + ? const [] + : profileOwnedMentionPubkeys.split('\u0000'), + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: post.mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); final preview = post.content.length > 200 ? '${post.content.substring(0, 200)}...' : post.content; @@ -128,6 +171,7 @@ class ForumPostCard extends ConsumerWidget { child: MessageContent( content: preview, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: post.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index d2e8490d6..68d2562f6 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -209,21 +210,27 @@ class _ThreadContent extends HookConsumerWidget { final post = thread.post; final replies = thread.replies; - // Preload profiles for all participants. + // Preload profiles for all participants and tagged mentions. final allPubkeys = useMemoized(() { - final pks = {post.pubkey}; + final pks = { + post.pubkey.toLowerCase(), + ...post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()), + }; for (final reply in replies) { - pks.add(reply.pubkey); + pks + ..add(reply.pubkey.toLowerCase()) + ..addAll(reply.mentionPubkeys.map((pubkey) => pubkey.toLowerCase())); } - return pks.toList(); + return pks.toList()..sort(); }, [post, replies]); + final allPubkeysKey = allPubkeys.join('\u0000'); useEffect(() { if (allPubkeys.isNotEmpty) { ref.read(userCacheProvider.notifier).preload(allPubkeys); } return null; - }, [allPubkeys]); + }, [allPubkeysKey]); return Column( children: [ @@ -322,7 +329,19 @@ class _OriginalPost extends ConsumerWidget { final displayName = profile?.label ?? _shortPubkey(post.pubkey); final userCache = ref.watch(userCacheProvider); - final mentionNames = _buildMentionNames(post.mentionPubkeys, userCache); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: post.mentionPubkeys, + profileMentionNames: _buildMentionNames(post.mentionPubkeys, userCache), + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: const EdgeInsets.all(Grid.xs), @@ -375,6 +394,7 @@ class _OriginalPost extends ConsumerWidget { MessageContent( content: post.content, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: post.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, @@ -409,7 +429,19 @@ class _ReplyRow extends ConsumerWidget { final displayName = profile?.label ?? _shortPubkey(reply.pubkey); final userCache = ref.watch(userCacheProvider); - final mentionNames = _buildMentionNames(reply.mentionPubkeys, userCache); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: reply.mentionPubkeys, + profileMentionNames: _buildMentionNames(reply.mentionPubkeys, userCache), + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: const EdgeInsets.symmetric( @@ -481,6 +513,7 @@ class _ReplyRow extends ConsumerWidget { child: MessageContent( content: reply.content, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: reply.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index b608b65ae..65fc12dfd 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -3,6 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -597,7 +599,7 @@ class _PeopleSection extends ConsumerWidget { } } -class _MessagesSection extends ConsumerWidget { +class _MessagesSection extends HookConsumerWidget { final List hits; final String? currentPubkey; final VoidCallback onResultSelected; @@ -614,8 +616,15 @@ class _MessagesSection extends ConsumerWidget { final channels = ref.watch(channelsProvider).value ?? []; // Preload author profiles. - final pubkeys = hits.map((h) => h.pubkey.toLowerCase()).toSet().toList(); - ref.read(userCacheProvider.notifier).preload(pubkeys); + final preloadPubkeys = { + for (final hit in hits) hit.pubkey.toLowerCase(), + for (final hit in hits) ...mentionedPubkeysFromTags(hit.tags), + }.toList()..sort(); + final preloadPubkeysKey = preloadPubkeys.join('\u0000'); + useEffect(() { + ref.read(userCacheProvider.notifier).preload(preloadPubkeys); + return null; + }, [preloadPubkeysKey]); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -635,7 +644,7 @@ class _MessagesSection extends ConsumerWidget { } } -class _MessageTile extends StatelessWidget { +class _MessageTile extends ConsumerWidget { final SearchHit hit; final UserProfile? authorProfile; final Map userCache; @@ -653,12 +662,34 @@ class _MessageTile extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey); final timeAgo = relativeTime(hit.createdAt); final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), ''); final hasChannelName = channelName != null && channelName.isNotEmpty; final isDm = channel?.isDm ?? false; + final profileMentionNames = { + for (final pubkey in mentionedPubkeysFromTags(hit.tags)) + if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: userCache[pubkey]!.displayName!.trim(), + }; + final mentionPubkeys = mentionedPubkeysFromTags(hit.tags); + final knownAgentPubkeys = channel == null + ? ref.watch(knownAgentPubkeysProvider) + : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: knownAgentPubkeys, + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return ListTile( key: ValueKey('search-message-row-${hit.eventId}'), @@ -730,6 +761,8 @@ class _MessageTile extends StatelessWidget { MessageContent( key: ValueKey('search-message-body-${hit.eventId}'), content: hit.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: hit.tags, maxLines: 2, baseStyle: activityPreviewTextStyle.copyWith( diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart new file mode 100644 index 000000000..ea6a2ee50 --- /dev/null +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -0,0 +1,274 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/crypto/nip_oa.dart'; +import '../../shared/relay/relay.dart'; + +/// A relay agent parsed from its kind:10100 agent-profile event. +/// +/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility +/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which +/// channels it sits in. +class AgentDirectoryEntry { + final String pubkey; + final String? displayName; + final String? respondTo; + final List respondToAllowlist; + final List channelIds; + + const AgentDirectoryEntry({ + required this.pubkey, + this.displayName, + this.respondTo, + this.respondToAllowlist = const [], + this.channelIds = const [], + }); + + factory AgentDirectoryEntry.fromEvent(NostrEvent event) { + final content = _tryDecodeJsonMap(event.content); + return AgentDirectoryEntry( + pubkey: event.pubkey.toLowerCase(), + displayName: + (content?['display_name'] as String?) ?? + (content?['name'] as String?), + respondTo: content?['respond_to'] as String?, + respondToAllowlist: [ + for (final value in (content?['respond_to_allowlist'] as List?) ?? []) + if (value is String) value.toLowerCase(), + ], + channelIds: [ + for (final value in (content?['channel_ids'] as List?) ?? []) + if (value is String) value, + ], + ); + } +} + +Map? _tryDecodeJsonMap(String content) { + try { + final decoded = jsonDecode(content); + return decoded is Map ? decoded : null; + } catch (_) { + return null; + } +} + +/// Relay agent directory from kind:10100 agent-profile events. +/// +/// Watches the session and only fetches after the WebSocket connects. +final agentDirectoryProvider = FutureProvider>(( + ref, +) async { + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) return const []; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory(NostrFilters.agentProfiles()); + return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; +}); + +/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 +/// profiles. An entry exists only when the `auth` tag verifies — mirrors +/// desktop's `profile_valid_oa_owner_pubkey`. +final agentOwnersProvider = FutureProvider>((ref) async { + final agents = await ref.watch(agentDirectoryProvider.future); + if (agents.isEmpty) return const {}; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), + ); + final owners = {}; + for (final event in events) { + final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); + if (owner != null) owners[event.pubkey.toLowerCase()] = owner; + } + return owners; +}); + +/// Pubkeys currently known to represent agents across the active relay. +/// +/// Message surfaces that do not own channel membership can use this shared +/// identity source; channel features add their bot roles separately. +final knownAgentPubkeysProvider = Provider>((ref) { + final relayAgents = + ref.watch(agentDirectoryProvider).asData?.value ?? + const []; + final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + return _AgentPubkeySet({ + for (final agent in relayAgents) agent.pubkey.toLowerCase(), + ...owners.keys.map((pubkey) => pubkey.toLowerCase()), + }); +}); + +/// Directory display names keyed by agent pubkey for mention presentation. +final agentDirectoryDisplayNamesProvider = Provider>((ref) { + final agents = + ref.watch(agentDirectoryProvider).asData?.value ?? + const []; + return Map.unmodifiable({ + for (final agent in agents) + if (agent.displayName?.trim().isNotEmpty == true) + agent.pubkey.toLowerCase(): agent.displayName!.trim(), + }); +}); + +/// Adds channel bot roles to relay-wide agent identities. +Set agentPubkeysWithChannelBots({ + required Set knownAgentPubkeys, + required Iterable channelBotPubkeys, +}) => _AgentPubkeySet({ + ...knownAgentPubkeys, + ...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()), +}); + +/// Adds agent identities derived from locally cached verified profiles. +Set agentPubkeysWithProfileOwners({ + required Set knownAgentPubkeys, + required Iterable profileOwnedAgentPubkeys, +}) => _AgentPubkeySet({ + ...knownAgentPubkeys, + ...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()), +}); + +/// Preserves profile labels while filling missing agent mentions from the +/// relay's agent directory. +Map mentionNamesWithDirectoryLabels({ + required Iterable mentionPubkeys, + required Map profileMentionNames, + required Map directoryDisplayNames, + required Set agentMentionPubkeys, +}) { + final names = Map.from(profileMentionNames); + for (final pubkey in mentionPubkeys) { + final normalizedPubkey = pubkey.toLowerCase(); + if (names[normalizedPubkey]?.trim().isEmpty == true) { + names.remove(normalizedPubkey); + } + final directoryName = directoryDisplayNames[normalizedPubkey]; + if (!names.containsKey(normalizedPubkey) && directoryName != null) { + names[normalizedPubkey] = directoryName; + } + if (!names.containsKey(normalizedPubkey) && + agentMentionPubkeys.contains(normalizedPubkey)) { + names[normalizedPubkey] = _agentFallbackLabel(normalizedPubkey); + } + } + return names; +} + +String _agentFallbackLabel(String pubkey) => + pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey; + +/// Keeps the role feed alive for consumers that render mentions outside the +/// channel timeline, such as search results. A membership change refreshes the +/// shared bot-role lookup below, regardless of which surface owns the channel. +class _ChannelBotRoleSubscription extends Notifier { + final String channelId; + void Function()? _unsubscribe; + int _subscriptionVersion = 0; + + _ChannelBotRoleSubscription(this.channelId); + + @override + int build() { + final sessionState = ref.watch(relaySessionProvider); + final subscriptionVersion = ++_subscriptionVersion; + _clearSubscription(); + ref.onDispose(() { + _subscriptionVersion++; + _clearSubscription(); + }); + + if (sessionState.status != SessionStatus.connected) return 0; + Future.microtask(() => _subscribe(channelId, subscriptionVersion)); + return 0; + } + + Future _subscribe(String channelId, int subscriptionVersion) async { + final session = ref.read(relaySessionProvider.notifier); + try { + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: const [39002], + tags: { + '#h': [channelId], + }, + ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000), + (_) { + if (_isCurrent(subscriptionVersion)) { + state++; + } + }, + ); + if (!_isCurrent(subscriptionVersion)) { + unsubscribe(); + return; + } + _unsubscribe = unsubscribe; + } catch (error) { + if (_isCurrent(subscriptionVersion)) { + debugPrint( + '[ChannelBotRoleSubscription] failed for $channelId: $error', + ); + } + } + } + + bool _isCurrent(int subscriptionVersion) => + subscriptionVersion == _subscriptionVersion; + + void _clearSubscription() { + _unsubscribe?.call(); + _unsubscribe = null; + } +} + +/// Monotonically increments when the channel's kind:39002 membership snapshot +/// changes. Channel-member and agent-role views share this source so remote +/// membership updates refresh both snapshots together. +final channelMembershipUpdateProvider = NotifierProvider.autoDispose + .family<_ChannelBotRoleSubscription, int, String>( + _ChannelBotRoleSubscription.new, + ); + +/// Bot pubkeys currently assigned a channel bot role. +final channelBotPubkeysProvider = FutureProvider.autoDispose + .family, String>((ref, channelId) async { + ref.watch(channelMembershipUpdateProvider(channelId)); + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) return const {}; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.channelMembers(channelId), + ); + if (events.isEmpty) return const {}; + return _AgentPubkeySet({ + for (final member in membersFromEvent(events.first)) + if (member.role == 'bot') member.pubkey.toLowerCase(), + }); + }); + +/// Pubkeys currently known to represent agents in a channel. +final agentMentionPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final channelBotPubkeys = + ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ?? + const {}; + return agentPubkeysWithChannelBots( + knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider), + channelBotPubkeys: channelBotPubkeys, + ); + }); + +class _AgentPubkeySet extends UnmodifiableSetView { + _AgentPubkeySet(Iterable pubkeys) : super(Set.unmodifiable(pubkeys)); + + @override + bool operator ==(Object other) => + other is Set && length == other.length && every(other.contains); + + @override + int get hashCode => Object.hashAllUnordered(this); +} diff --git a/mobile/lib/shared/mentions/mention_tags.dart b/mobile/lib/shared/mentions/mention_tags.dart new file mode 100644 index 000000000..bf2128271 --- /dev/null +++ b/mobile/lib/shared/mentions/mention_tags.dart @@ -0,0 +1,6 @@ +/// Pubkeys tagged as message mentions, normalized for profile lookups. +Set mentionedPubkeysFromTags(Iterable> tags) => { + for (final tag in tags) + if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention')) + tag[1].toLowerCase(), +}; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index e5ed0fb35..1c6609389 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -26,6 +26,7 @@ import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; @@ -188,6 +189,9 @@ Widget _buildTestable({ channelMembersProvider(_channelId).overrideWith( (ref) async => loadMembers != null ? loadMembers() : members, ), + channelBotPubkeysProvider( + _channelId, + ).overrideWith((ref) async => const {}), if (createChannelActions != null) channelActionsProvider.overrideWith(createChannelActions), if (readStateNotifier != null) diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 7b747adfa..919d4039e 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -17,11 +17,10 @@ import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/channels/mentions/mention_candidates.dart'; -import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; import 'package:buzz/features/channels/photo_library.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -2269,6 +2268,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); @@ -2285,6 +2289,70 @@ void main() { ]); }); + testWidgets( + 'renders chips only for selected agents outside code and composition', + (tester) async { + final semantics = tester.ensureSemantics(); + final signer = nostr.Keys.generate(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayAgents: [_testAgent('f' * 64)], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@Helper Bot'); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsNothing, + ); + + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Agent mention: Helper Bot'), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Helper Bot'), findsNothing); + + await tester.enterText(find.byType(TextField), '`@Helper Bot`'); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsNothing, + ); + + await tester.enterText(find.byType(TextField), '@Helper Bot typing'); + final textField = tester.widget(find.byType(TextField)); + textField.controller!.value = textField.controller!.value.copyWith( + composing: const TextRange(start: 12, end: 18), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); + await tester.pump(const Duration(milliseconds: 250)); + semantics.dispose(); + }, + ); + testWidgets('does not mutate a DM when mentioning a non-member agent', ( tester, ) async { diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 3f2e8e3d6..811996857 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; final userPubkey = 'a' * 64; final memberPubkey = 'b' * 64; @@ -18,6 +19,20 @@ ChannelMember member(String pubkey, {String role = 'member'}) { } void main() { + test('role-only agent mentions fall back to a pubkey prefix label', () { + const pubkey = 'deadbeef0123456789'; + + expect( + mentionNamesWithDirectoryLabels( + mentionPubkeys: const [pubkey], + profileMentionNames: const {}, + directoryDisplayNames: const {}, + agentMentionPubkeys: const {pubkey}, + ), + const {pubkey: 'deadbeef'}, + ); + }); + group('agentIsSharedWithUser', () { test('anyone-mode agent is shared when a channel overlaps', () { final agent = AgentDirectoryEntry( diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index bc6772fd6..9d8adb69b 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1204,6 +1204,43 @@ Photos expect(find.text('Alice'), findsOneWidget); }); + testWidgets('renders a known agent mention with the bot chip', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Ask @Helper Bot to investigate', + mentionNames: {'agent-pubkey': 'Helper Bot'}, + agentMentionPubkeys: {'agent-pubkey'}, + maxLines: 2, + ), + ), + ); + + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect(find.text('@'), findsNothing); + expect(find.text('Helper Bot'), findsOneWidget); + }); + + testWidgets('normalizes passed multi-word agent mentions', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Ask @Helper Bot to investigate', + mentionNames: {'agent-pubkey': 'Helper Bot'}, + agentMentionPubkeys: {'agent-pubkey'}, + ), + ), + ); + + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect(find.text('Helper Bot'), findsOneWidget); + expect(_allRichText(tester), isNot(contains('Bot Bot'))); + }); + testWidgets('highlights an entire multi-word display name', ( tester, ) async { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index e4a576b91..c3db833fc 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -10,8 +10,10 @@ import 'package:buzz/features/search/recent_searches_provider.dart'; import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; @@ -593,6 +595,72 @@ void main() { await tester.pump(); expect(recentSearches.searches, const ['design']); }); + + testWidgets('renders channel-role bots in message previews', (tester) async { + final channel = Channel( + id: 'channel-1', + name: 'general', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + ); + const agentPubkey = 'agent-pubkey'; + const cachedProfile = UserProfile(pubkey: 'author-pubkey'); + final state = SearchState( + query: 'helper', + messageResults: [ + SearchHit( + eventId: 'message-1', + content: 'Ask @Helper Bot to investigate', + kind: 9, + pubkey: 'author-pubkey', + channelId: channel.id, + channelName: channel.name, + createdAt: 1, + score: 1, + tags: [ + ['p', agentPubkey], + ], + ), + ], + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier([channel])), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier(cachedProfile), + ), + knownAgentPubkeysProvider.overrideWith((ref) => const {}), + channelBotPubkeysProvider( + channel.id, + ).overrideWith((ref) async => {agentPubkey}), + agentDirectoryDisplayNamesProvider.overrideWith( + (ref) => const {agentPubkey: 'Helper Bot'}, + ), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final content = tester.widget( + find.byKey(const ValueKey('search-message-body-message-1')), + ); + expect(content.mentionNames, const {agentPubkey: 'Helper Bot'}); + expect(content.agentMentionPubkeys, contains(agentPubkey)); + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + }); } class _FakeSearchNotifier extends SearchNotifier { @@ -646,8 +714,12 @@ class _FakeProfileNotifier extends ProfileNotifier { } class _FakeChannelsNotifier extends ChannelsNotifier { + _FakeChannelsNotifier([this.channels = const []]); + + final List channels; + @override - Future> build() async => const []; + Future> build() async => channels; } class _FakeUserCacheNotifier extends UserCacheNotifier { diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart new file mode 100644 index 000000000..0ea0afb26 --- /dev/null +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; + +void main() { + test('refreshes channel bot roles from live membership updates', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + expect(await container.read(channelBotPubkeysProvider(_channelId).future), { + _agentPubkey, + }); + await relaySession.subscribed; + expect(relaySession.liveFilters.single.kinds, const [39002]); + expect(relaySession.liveFilters.single.tags['#h'], [_channelId]); + + relaySession.emit(_membershipEvent(role: 'member')); + await _pumpEventQueue(); + + expect( + await container.read(channelBotPubkeysProvider(_channelId).future), + isEmpty, + ); + }); + + test('refreshes channel members from live membership updates', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelMembersProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + expect( + (await container.read( + channelMembersProvider(_channelId).future, + )).single.role, + 'bot', + ); + await relaySession.subscribed; + + relaySession.emit(_membershipEvent(role: 'member')); + await _pumpEventQueue(); + + expect( + (await container.read( + channelMembersProvider(_channelId).future, + )).single.role, + 'member', + ); + }); + + test('disposes the live role subscription without consumers', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + + await container.read(channelBotPubkeysProvider(_channelId).future); + await relaySession.subscribed; + keepAlive.close(); + await container.pump(); + + expect(relaySession.unsubscribeCount, 1); + }); + + test( + 'does not retain a live role subscription through working bots', + () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + workingBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + + await relaySession.subscribed; + keepAlive.close(); + await container.pump(); + + expect(relaySession.unsubscribeCount, 1); + }, + ); + + test('blank profile labels defer to the directory label', () { + const pubkey = 'deadbeef0123456789'; + + expect( + mentionNamesWithDirectoryLabels( + mentionPubkeys: const [pubkey], + profileMentionNames: const {pubkey: ' '}, + directoryDisplayNames: const {pubkey: 'Directory bot'}, + agentMentionPubkeys: const {pubkey}, + ), + const {pubkey: 'Directory bot'}, + ); + }); +} + +const _channelId = '11111111-1111-4111-8111-111111111111'; +const _agentPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +NostrEvent _membershipEvent({required String role}) => NostrEvent( + id: 'membership-$role', + pubkey: 'owner', + createdAt: 1, + kind: 39002, + tags: [ + ['d', _channelId], + ['h', _channelId], + ['p', _agentPubkey, 'wss://relay.example', role], + ], + content: '', + sig: 'sig', +); + +Future _pumpEventQueue() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +class _MembershipRelaySessionNotifier extends RelaySessionNotifier { + final List _memberships; + final List liveFilters = []; + final List<_LiveSubscription> _subscriptions = []; + final Completer _subscribed = Completer(); + var unsubscribeCount = 0; + var _membershipIndex = 0; + + _MembershipRelaySessionNotifier(this._memberships); + + Future get subscribed => _subscribed.future; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + return [_memberships[_membershipIndex++]]; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + liveFilters.add(filter); + final subscription = _LiveSubscription(filter, onEvent); + _subscriptions.add(subscription); + if (!_subscribed.isCompleted) _subscribed.complete(); + return () { + unsubscribeCount++; + _subscriptions.remove(subscription); + }; + } + + void emit(NostrEvent event) { + for (final subscription in List.of(_subscriptions)) { + if (_matches(subscription.filter, event)) { + subscription.onEvent(event); + } + } + } +} + +class _LiveSubscription { + final NostrFilter filter; + final void Function(NostrEvent) onEvent; + + const _LiveSubscription(this.filter, this.onEvent); +} + +bool _matches(NostrFilter filter, NostrEvent event) { + if (!filter.kinds.contains(event.kind)) return false; + return filter.tags.entries.every((entry) { + final tagName = entry.key.substring(1); + return event.tags.any( + (tag) => + tag.isNotEmpty && + tag.first == tagName && + tag.skip(1).any(entry.value.contains), + ); + }); +} diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py new file mode 100755 index 000000000..d6518b26b --- /dev/null +++ b/scripts/desktop_release.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Generate and validate immutable desktop release candidates.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHANGELOG = ROOT / "CHANGELOG.md" +METADATA = ROOT / ".release" / "desktop-candidate.json" +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") +DESKTOP_PATHS = ( + "desktop/", + "crates/buzz-core/", + "crates/buzz-persona/", + "crates/buzz-sdk/", + "crates/buzz-agent/", + "crates/buzz-media/", +) +CANDIDATE_FILES = { + ".release/desktop-candidate.json", + "CHANGELOG.md", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", + "desktop/src-tauri/Cargo.lock", + "pnpm-lock.yaml", +} +REQUIRED_CANDIDATE_FILES = { + ".release/desktop-candidate.json", + "CHANGELOG.md", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", +} + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[dict[str, str]]: + args = ["log", range_spec, "--no-merges", "--format=%H%x00%s"] + if paths: + args += ["--", *paths] + out = git(*args) + if not out: + return [] + return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()] + + +def stable_tags(base_sha: str) -> list[tuple[int, str, str]]: + tags: list[tuple[int, str, str]] = [] + for tag in git("tag", "--merged", base_sha, "--list").splitlines(): + if not re.fullmatch(r"(?:desktop-)?v[0-9]+\.[0-9]+\.[0-9]+", tag): + continue + sha = git("rev-list", "-n", "1", tag) + distance = int(git("rev-list", "--count", f"{sha}..{base_sha}")) + tags.append((distance, tag, sha)) + return tags + + +def previous_tag(base_sha: str) -> str: + tags = stable_tags(base_sha) + if not tags: + return "" + min_distance = min(item[0] for item in tags) + nearest = [item for item in tags if item[0] == min_distance] + commits = {item[2] for item in nearest} + if len(commits) != 1: + detail = ", ".join(f"{tag}@{sha}" for _, tag, sha in nearest) + raise SystemExit(f"ambiguous previous desktop release tags: {detail}") + # During migration, prefer the namespaced tag when aliases share a commit. + nearest.sort(key=lambda item: (not item[1].startswith("desktop-v"), item[1])) + return nearest[0][1] + + +def bullet(commit: dict[str, str], repo: str) -> str: + sha, subject = commit["sha"], commit["subject"] + short = sha[:12] + pr_match = re.search(r" \(#([0-9]+)\)$", subject) + if pr_match: + pr = pr_match.group(1) + subject = subject[: pr_match.start()] + return f"- {subject} ([#{pr}](https://github.com/{repo}/pull/{pr})) ([`{sha}`](https://github.com/{repo}/commit/{sha}))" + return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))" + + +def expected(base_sha: str, previous: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + # With no prior desktop tag, account for the repository's root commit too. + # A ``root..base`` range silently drops that first commit. + range_spec = f"{previous}..{base_sha}" if previous else base_sha + all_commits = commit_list(range_spec) + relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} + relevant = [c for c in all_commits if c["sha"] in relevant_shas] + other = [c for c in all_commits if c["sha"] not in relevant_shas] + return relevant, other + + +def render(version: str, base_sha: str, previous: str, repo: str) -> tuple[str, list[str]]: + relevant, other = expected(base_sha, previous) + lines = [f"## v{version}", "", "### Desktop and shared changes", ""] + lines += [bullet(c, repo) for c in relevant] or ["- None"] + lines += ["", "### Other repository changes", ""] + lines += [bullet(c, repo) for c in other] or ["- None"] + compare_start = previous or git("rev-list", "--max-parents=0", base_sha).splitlines()[0] + lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"] + return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other] + + +def generate(args: argparse.Namespace) -> None: + if not SEMVER.fullmatch(args.version): + raise SystemExit(f"invalid semver: {args.version}") + base_sha = git("rev-parse", args.base) + previous = previous_tag(base_sha) + repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git") + block, commits = render(args.version, base_sha, previous, repo) + old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n" + if not old.startswith("# Changelog"): + raise SystemExit("CHANGELOG.md must begin with '# Changelog'") + remainder = old.split("\n", 1)[1].lstrip("\n") if "\n" in old else "" + CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}") + METADATA.parent.mkdir(parents=True, exist_ok=True) + METADATA.write_text(json.dumps({ + "schema": 1, + "version": args.version, + "base_sha": base_sha, + "previous_tag": previous or None, + "tag": f"desktop-v{args.version}", + "commit_count": len(commits), + }, indent=2) + "\n") + + +def validate(args: argparse.Namespace) -> None: + data = json.loads(METADATA.read_text()) + version = args.version or data["version"] + if data != {**data, "version": version}: + raise SystemExit("candidate version does not match metadata") + if data["tag"] != f"desktop-v{version}": + raise SystemExit("candidate tag does not match version") + candidate = git("rev-parse", args.candidate) + parents = git("show", "-s", "--format=%P", candidate).split() + if len(parents) != 1 or parents[0] != data["base_sha"]: + raise SystemExit("candidate must be one commit directly above recorded base_sha") + changed = set(git("diff-tree", "--no-commit-id", "--name-only", "-r", candidate).splitlines()) + unexpected = changed - CANDIDATE_FILES + missing = REQUIRED_CANDIDATE_FILES - changed + if unexpected or missing: + detail = [] + if unexpected: + detail.append(f"unexpected files: {', '.join(sorted(unexpected))}") + if missing: + detail.append(f"missing required files: {', '.join(sorted(missing))}") + raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")") + previous = data["previous_tag"] or "" + actual_previous = previous_tag(data["base_sha"]) + if previous != actual_previous: + raise SystemExit( + f"recorded previous tag {previous or ''} does not match " + f"nearest release tag {actual_previous or ''}" + ) + repo = args.repo or "block/buzz" + expected_block, shas = render(version, data["base_sha"], previous, repo) + text = CHANGELOG.read_text() + blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text) + if len(blocks) != 1: + raise SystemExit(f"expected exactly one changelog block for v{version}") + if blocks[0].rstrip() != expected_block.rstrip(): + raise SystemExit("changelog block is not deterministic for recorded candidate base") + found = re.findall(r"\[`([0-9a-f]{40})`\]", blocks[0]) + if len(found) != len(set(found)) or set(found) != set(shas) or len(found) != data["commit_count"]: + raise SystemExit("changelog does not account for every expected non-merge commit exactly once") + manifests = { + ROOT / "desktop/package.json": json.loads((ROOT / "desktop/package.json").read_text())["version"], + ROOT / "desktop/src-tauri/tauri.conf.json": json.loads((ROOT / "desktop/src-tauri/tauri.conf.json").read_text())["version"], + } + cargo = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "desktop/src-tauri/Cargo.toml").read_text()) + manifests[ROOT / "desktop/src-tauri/Cargo.toml"] = cargo.group(1) if cargo else "" + bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version] + if bad: + raise SystemExit(f"version mismatch in: {', '.join(bad)}") + author = git("show", "-s", "--format=%an <%ae>", candidate) + body = git("show", "-s", "--format=%B", candidate) + if author != "Wes ": + raise SystemExit(f"unexpected candidate author: {author}") + if "Signed-off-by: Wes " not in body: + raise SystemExit("candidate is missing Wes Signed-off-by trailer") + if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body): + raise SystemExit("candidate is missing automation Co-authored-by trailer") + print(f"validated immutable desktop candidate {candidate} for desktop-v{version}") + + +def main() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + gen = sub.add_parser("generate") + gen.add_argument("version") + gen.add_argument("--base", required=True) + gen.add_argument("--repo") + val = sub.add_parser("validate") + val.add_argument("--candidate", default="HEAD") + val.add_argument("--version") + val.add_argument("--repo") + args = parser.parse_args() + generate(args) if args.command == "generate" else validate(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh new file mode 100755 index 000000000..c477a84cd --- /dev/null +++ b/scripts/prepare-desktop-release.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${1:-}" +mode="${2:-publish}" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { + echo "usage: $0 [publish|validate-only]" >&2 + exit 1 +} + +remote="${RELEASE_REMOTE:-origin}" +git fetch "$remote" refs/heads/main:refs/remotes/origin/main --no-tags +git fetch "$remote" '+refs/tags/v*:refs/tags/v*' '+refs/tags/desktop-v*:refs/tags/desktop-v*' +base_sha="$(git rev-parse refs/remotes/origin/main)" +branch="version-bump/$version" + +remote_branch="refs/heads/$branch" +remote_oid="" +if remote_oid="$(git ls-remote "$remote" "$remote_branch" | awk '{print $1}')" && [[ -n "$remote_oid" ]]; then + git fetch "$remote" "$remote_branch:refs/remotes/origin/$branch" +fi + +git checkout -B "$branch" "$base_sha" +just bump-desktop-version "$version" +scripts/desktop_release.py generate "$version" --base "$base_sha" --repo block/buzz + +git add \ + .release/desktop-candidate.json \ + CHANGELOG.md \ + desktop/package.json \ + desktop/src-tauri/tauri.conf.json \ + desktop/src-tauri/Cargo.toml \ + desktop/src-tauri/Cargo.lock \ + pnpm-lock.yaml + +agent_name="${RELEASE_AUTOMATION_NAME:-${AGENT_NAME:-Release Automation}}" +agent_email="${RELEASE_AUTOMATION_EMAIL:-${AGENT_EMAIL:-release-automation@users.noreply.github.com}}" +msg="$(mktemp)" +trap 'rm -f "$msg"' EXIT +cat >"$msg" < +EOF +git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \ + commit -s -F "$msg" +scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz + +candidate_sha="$(git rev-parse HEAD)" +previous_tag="$(python3 -c 'import json; print(json.load(open(".release/desktop-candidate.json"))["previous_tag"] or "initial")')" +printf 'base_sha=%s\ncandidate_sha=%s\nprevious_tag=%s\ntag=desktop-v%s\n' \ + "$base_sha" "$candidate_sha" "$previous_tag" "$version" + +if [[ "$mode" == validate-only ]]; then + exit 0 +fi +[[ "$mode" == publish ]] || { echo "unknown mode: $mode" >&2; exit 1; } +if [[ -n "$remote_oid" ]]; then + git push --force-with-lease="$remote_branch:$remote_oid" "$remote" "HEAD:$remote_branch" +else + git push --force-with-lease="$remote_branch:" "$remote" "HEAD:$remote_branch" +fi + +body="$(mktemp)" +trap 'rm -f "$msg" "$body"' EXIT +cat >"$body" < "$tmp/desktop/package.json" +printf '{"version":"1.0.0"}\n' > "$tmp/desktop/src-tauri/tauri.conf.json" +printf '[package]\nversion = "1.0.0"\n' > "$tmp/desktop/src-tauri/Cargo.toml" +echo '# Changelog' > "$tmp/CHANGELOG.md" +echo first > "$tmp/desktop/feature" +git -C "$tmp" add . +git -C "$tmp" commit -qm 'feat: first desktop change' +git -C "$tmp" -c tag.gpgSign=false tag v1.0.0 +echo second >> "$tmp/desktop/feature" +git -C "$tmp" commit -qam 'fix: desktop fix' +echo policy > "$tmp/POLICY.md" +git -C "$tmp" add POLICY.md +git -C "$tmp" commit -qm 'docs: repository policy' +base=$(git -C "$tmp" rev-parse HEAD) +( + cd "$tmp" + scripts/desktop_release.py generate 1.0.1 --base "$base" --repo block/buzz + python3 - <<'PY' +import json +for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'): + data=json.load(open(path)); data['version']='1.0.1'; open(path,'w').write(json.dumps(data)+'\n') +p='desktop/src-tauri/Cargo.toml'; open(p,'w').write('[package]\nversion = "1.0.1"\n') +PY + rm -f msg + git add . + cat >msg <<'EOF' +chore(release): release Buzz Desktop version 1.0.1 + +Co-authored-by: Test Automation +EOF + git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -F msg + rm msg + scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + grep -Fq '### Other repository changes' CHANGELOG.md + grep -Fq "$(git rev-parse HEAD~1)" CHANGELOG.md + grep -Fq "$(git rev-parse HEAD~2)" CHANGELOG.md + + # Metadata cannot lie about the prior release boundary. + cp .release/desktop-candidate.json metadata.json + python3 - <<'PY' +import json +p='.release/desktop-candidate.json'; d=json.load(open(p)); d['previous_tag']=None; open(p,'w').write(json.dumps(d)+'\n') +PY + if scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a forged previous release tag" >&2 + exit 1 + fi + mv metadata.json .release/desktop-candidate.json +) + +# An initial release must account for the root commit, not silently omit it. +initial=$(mktemp -d) +cp "$repo_root/scripts/desktop_release.py" "$initial/desktop_release.py" +git -C "$initial" init -q +git -C "$initial" config user.name test +git -C "$initial" config user.email test@example.com +mkdir -p "$initial/scripts" "$initial/desktop/src-tauri" +mv "$initial/desktop_release.py" "$initial/scripts/desktop_release.py" +printf '{"version":"0.1.0"}\n' > "$initial/desktop/package.json" +printf '{"version":"0.1.0"}\n' > "$initial/desktop/src-tauri/tauri.conf.json" +printf '[package]\nversion = "0.1.0"\n' > "$initial/desktop/src-tauri/Cargo.toml" +printf '# Changelog\n' > "$initial/CHANGELOG.md" +echo root > "$initial/ROOT.md" +git -C "$initial" add . +git -C "$initial" commit -qm 'feat: root release content' +root_sha=$(git -C "$initial" rev-parse HEAD) +(cd "$initial" && scripts/desktop_release.py generate 0.1.0 --base "$root_sha" --repo block/buzz) +grep -Fq "$root_sha" "$initial/CHANGELOG.md" +rm -rf "$initial" + +echo "desktop release candidate contract passed" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 0d810819d..bd4eb7527 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -12,16 +12,16 @@ git -C "$tmp" config user.email test@example.com echo first >"$tmp/file" git -C "$tmp" add file git -C "$tmp" commit -qm first -git -C "$tmp" tag -m "desktop release" v1.2.3 +git -C "$tmp" tag -m "desktop release" desktop-v1.2.3 ( cd "$tmp" - GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 + GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3 ) if ( cd "$tmp" - GITHUB_REF=refs/heads/main "$verify" v 1.2.3 + GITHUB_REF=refs/heads/main "$verify" desktop-v 1.2.3 ); then echo "branch-backed desktop release was accepted" >&2 exit 1 @@ -31,7 +31,7 @@ echo second >>"$tmp/file" git -C "$tmp" commit -qam second if ( cd "$tmp" - GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 + GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3 ); then echo "release accepted HEAD after the tag commit" >&2 exit 1 @@ -61,6 +61,73 @@ grep -q 'private-key:.*secrets\.BUZZ_RELEASE_TAGGER_PRIVATE_KEY' "$auto_tag" grep -q 'permission-contents: write' "$auto_tag" grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag" grep -Fq 'git/refs' "$auto_tag" +grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag" +grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag" +grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag" +review_filter="$repo_root/scripts/review-decision-approved.jq" +for fixture in \ + '{"reviewDecision":"CHANGES_REQUESTED"}' \ + '{"reviewDecision":"REVIEW_REQUIRED"}' \ + '{"reviewDecision":null}' \ + '{}'; do + if jq -e -f "$review_filter" <<<"$fixture" >/dev/null; then + echo "review-decision filter accepted non-approved fixture: $fixture" >&2 + exit 1 + fi +done +jq -e -f "$review_filter" >/dev/null <<'JSON' || { +{"reviewDecision":"APPROVED"} +JSON + echo "review-decision filter rejected approved GraphQL response" >&2 + exit 1 +} +required_check_filter="$repo_root/scripts/required-check-succeeded.jq" +check_fixture() { + local expected="$1" conclusion="$2" status="${3:-completed}" + local payload + payload=$(jq -n --arg status "$status" --arg conclusion "$conclusion" '{check_runs: [{name: "Web", status: $status, conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z"}]}') + if jq -e --arg name Web -f "$required_check_filter" <<<"[$payload]" >/dev/null; then + actual=pass + else + actual=fail + fi + [[ "$actual" == "$expected" ]] || { + echo "required-check filter: expected $conclusion/$status to $expected" >&2 + exit 1 + } +} +check_fixture pass success +check_fixture pass skipped +check_fixture pass neutral +check_fixture fail failure +check_fixture fail success in_progress +# A newer failure must not be hidden by an older successful run of the same check. +jq -e --arg name Web -f "$required_check_filter" >/dev/null <<'JSON' && { +[{"check_runs":[ + {"name":"Web","status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z"}, + {"name":"Web","status":"completed","conclusion":"failure","started_at":"2026-01-02T00:00:00Z"} +]}] +JSON + echo "required-check filter accepted a stale pass over a newer failure" >&2 + exit 1 +} +release_workflow="$repo_root/.github/workflows/release.yml" +[[ "$(grep -c 'contents: write' "$release_workflow")" -eq 1 ]] || { + echo "desktop release must have exactly one GitHub contents writer" >&2; exit 1; +} +grep -Fq "needs.release.result == 'success'" "$release_workflow" +grep -Fq "needs.release-macos-x64.result == 'success'" "$release_workflow" +grep -Fq "needs.release-linux.result == 'success'" "$release_workflow" +grep -Fq "needs.release-windows.result == 'success'" "$release_workflow" +grep -Fq "refs/tags/desktop-v{0}" "$release_workflow" +grep -Fq "if: \${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" +grep -Fq 'group: desktop-release-${{ github.ref }}' "$release_workflow" +grep -Fq 'cancel-in-progress: false' "$release_workflow" +grep -Fq 'release artifact basename collision' "$release_workflow" +[[ "$(grep -c 'gh release upload' "$release_workflow")" -eq 2 ]] || { + echo "only the final writer may upload versioned and rolling release assets" >&2; exit 1; +} +grep -Fq 'if: env.already_published' "$release_workflow" grep -Fq 'if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then' "$auto_tag" if grep -F 'git/ref/tags/$TAG' "$auto_tag" | grep -Fq '|| true'; then echo "auto-tag ignores a failed tag lookup, so a 404 body can look like an existing tag" >&2 diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh new file mode 100755 index 000000000..17bf2f4dc --- /dev/null +++ b/scripts/verify-desktop-release-merge.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PR_HEAD_SHA:?}" +: "${MERGE_SHA:?}" +: "${VERSION:?}" +: "${PR_NUMBER:?}" +: "${GH_TOKEN:?}" + +required_checks=( + "Desktop E2E Integration" + "Desktop" + "Rust Lint" + "Security" + "Unit Tests" + "Windows Rust (x86_64-pc-windows-msvc)" + "Mobile" + "Web" + "Backend Integration (relay e2e)" + "Desktop E2E Relay" + "Relay E2E" + "Desktop Build (macOS)" + "DCO Check" +) + +expected_branch="version-bump/$VERSION" +[[ "${PR_HEAD_REF:-}" == "$expected_branch" ]] || { echo "unexpected release branch" >&2; exit 1; } +[[ "${PR_BASE_REF:-}" == main ]] || { echo "desktop release must target main" >&2; exit 1; } +[[ "${PR_HEAD_REPO:-}" == "$GITHUB_REPOSITORY" ]] || { echo "desktop release must be internal" >&2; exit 1; } + +git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags +mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n') +[[ "${#parents[@]}" -eq 2 ]] || { echo "desktop release was not merged with a true merge commit" >&2; exit 1; } +[[ "${parents[1]}" == "$PR_HEAD_SHA" ]] || { echo "merge parent 2 is not the reviewed candidate" >&2; exit 1; } +git merge-base --is-ancestor "$PR_HEAD_SHA" origin/main || { echo "candidate is not reachable from current main" >&2; exit 1; } + +git checkout --detach "$PR_HEAD_SHA" +scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" + +review=$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest') +jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || { + echo "pull request effective review decision is not APPROVED" >&2 + exit 1 +} +reviews="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" +valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" +[[ "$valid_approvals" -gt 0 ]] || { echo "candidate lacks an exact-head approval from a repository member or collaborator" >&2; exit 1; } + +checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")" +for required in "${required_checks[@]}"; do + jq -e --arg name "$required" -f scripts/required-check-succeeded.jq <<<"$checks" >/dev/null || { + echo "required check is missing or unsuccessful: $required" >&2 + exit 1 + } +done +status="$(gh api "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/status")" +jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null || { + echo "candidate has a failing or pending combined commit status" >&2 + exit 1 +} + +echo "verified reviewed desktop candidate $PR_HEAD_SHA at merge $MERGE_SHA"