diff --git a/.env.example b/.env.example index 5e2dfd65477..c906b314b8b 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ REDIS_URL=redis://localhost:6379 # Max connections in the relay's shared Redis pool (default 16). # BUZZ_REDIS_POOL_SIZE=16 +# Max connections in each of the relay's Postgres pools — writer and, when +# READ_DATABASE_URL is set, reader (default 50). +# BUZZ_DB_POOL_SIZE=50 + # ----------------------------------------------------------------------------- # Typesense (search) # ----------------------------------------------------------------------------- @@ -110,7 +114,10 @@ RELAY_URL=ws://localhost:3000 # ----------------------------------------------------------------------------- # Logging / Tracing # ----------------------------------------------------------------------------- -RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug +RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug +# Optional OpenTelemetry-only target filter. This is deliberately independent +# from RUST_LOG so log verbosity changes cannot break trace parentage. +# BUZZ_OTEL_FILTER=buzz_relay=info,buzz_datastore=info # OTLP tracing endpoint (optional — leave unset to disable) # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8fda564d76..322da7eb277 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,9 @@ jobs: - 'rust-toolchain.toml' - 'deny.toml' - '.github/workflows/ci.yml' + - '.github/workflows/docker.yml' - 'scripts/run-tests.sh' + - 'scripts/test-docker-tag-matrix.sh' - 'justfile' desktop: - 'desktop/**' @@ -76,6 +78,8 @@ jobs: scripts/test-mobile-release-candidate-publisher.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh + - name: Docker tag matrix contract + run: scripts/test-docker-tag-matrix.sh rust-lint: name: Rust Lint @@ -340,6 +344,7 @@ jobs: cargo build --profile ci -p buzz-relay -p git-credential-nostr cargo nextest archive \ --cargo-profile ci \ + -p buzz-db \ -p buzz-relay \ -p buzz-test-client \ --lib \ @@ -671,11 +676,11 @@ jobs: done cat /tmp/buzz-relay.log exit 1 - - name: Invite claim security tests + - name: Invite security tests run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(claim_)' \ + -E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 66e8b017c08..967acce1880 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,8 @@ name: Docker image # Builds and publishes the MAC Workspace relay image as ghcr.io/marccopson/buzz. +# Normal tags contain stripped binaries; matching debug-* tags contain the same +# optimized binaries with line-table debug information for native profilers. # # Strategy: each architecture builds on its native runner (ubuntu-24.04 for # amd64, ubuntu-24.04-arm for arm64), pushes to GHCR by digest, then a final @@ -14,9 +16,11 @@ name: Docker image # the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop. # # Triggers: -# - push to main or mac → : + :sha-<40> +# - push to main or mac → : + :sha-<40> +# + :debug- + :debug-sha-<40> # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} -# (+ :latest for stable, NOT for prereleases) +# + matching :debug-* tags +# (+ :latest/:debug-latest for stable releases) # - pull_request → build only (no push), cache stays warm # - workflow_dispatch → manual relay-tag rescue at the tag itself # @@ -95,10 +99,6 @@ jobs: runner: ubuntu-24.04-arm arch: arm64 - outputs: - # Used downstream by `merge` to stitch the manifest. - version: ${{ steps.meta.outputs.version }} - steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -163,12 +163,13 @@ jobs: org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform org.opencontainers.image.licenses=Apache-2.0 - - name: Build and push by digest - id: build + - name: Build and push release image by digest + id: build-release uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: ./Dockerfile + target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} # Push by digest, not by tag — the merge job assembles the tags @@ -180,25 +181,49 @@ jobs: cache-to: | ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - - name: Export digest + - name: Build and push debug image by digest + id: build-debug + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile + target: runtime-debug + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + + - name: Export release and debug digests if: github.event_name != 'pull_request' env: - DIGEST: ${{ steps.build.outputs.digest }} + RELEASE_DIGEST: ${{ steps.build-release.outputs.digest }} + DEBUG_DIGEST: ${{ steps.build-debug.outputs.digest }} run: | - mkdir -p /tmp/digests - touch "/tmp/digests/${DIGEST#sha256:}" + mkdir -p /tmp/digests-release /tmp/digests-debug + touch "/tmp/digests-release/${RELEASE_DIGEST#sha256:}" + touch "/tmp/digests-debug/${DEBUG_DIGEST#sha256:}" - - name: Upload digest + - name: Upload release digest if: github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: digests-${{ matrix.arch }} - path: /tmp/digests/* + name: digests-release-${{ matrix.arch }} + path: /tmp/digests-release/* + if-no-files-found: error + retention-days: 1 + + - name: Upload debug digest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digests-debug-${{ matrix.arch }} + path: /tmp/digests-debug/* if-no-files-found: error retention-days: 1 merge: - name: Merge multi-arch manifest + name: Merge ${{ matrix.variant }} multi-arch manifest if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 needs: build @@ -208,13 +233,21 @@ jobs: packages: write # push the merged manifest id-token: write # OIDC for provenance attestation on the manifest attestations: write + strategy: + fail-fast: false + matrix: + include: + - variant: release + tag_prefix: "" + - variant: debug + tag_prefix: debug- steps: - name: Download all per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/digests - pattern: digests-* + pattern: digests-${{ matrix.variant }}-* merge-multiple: true - name: Set up Docker Buildx @@ -237,6 +270,9 @@ jobs: # the build job's `meta` step for why match=^relay-v(.*)$, why # value=${{ inputs.version }} carries the rescue-dispatch version, # and why :latest is left to flavor.latest=auto. + flavor: | + latest=auto + prefix=${{ matrix.tag_prefix }},onlatest=true tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=raw,value=sha-${{ github.sha }},enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} @@ -244,6 +280,22 @@ jobs: type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} + - name: Verify debug branch tag matrix + if: matrix.variant == 'debug' && github.ref_type == 'branch' + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + META_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + expected_branch="${IMAGE_NAME}:debug-${GITHUB_REF_NAME}" + expected_sha="${IMAGE_NAME}:debug-sha-${GITHUB_SHA}" + grep -Fxq "$expected_branch" <<< "$META_TAGS" + grep -Fxq "$expected_sha" <<< "$META_TAGS" + if grep -Fq "${IMAGE_NAME}:debug-debug-" <<< "$META_TAGS"; then + echo "debug tag prefix was applied twice" >&2 + exit 1 + fi + - name: Create and push manifest list id: manifest working-directory: /tmp/digests @@ -284,11 +336,12 @@ jobs: - name: Summary env: IMAGE_NAME: ${{ env.IMAGE_NAME }} + VARIANT: ${{ matrix.variant }} MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} META_TAGS: ${{ steps.meta.outputs.tags }} run: | { - echo "### Published \`${IMAGE_NAME}\`" + echo "### Published \`${IMAGE_NAME}\` (${VARIANT})" echo echo "**Digest:** \`${MERGED_DIGEST}\`" echo diff --git a/AGENTS.md b/AGENTS.md index cb4843a9efc..7ff0eb4d477 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -343,9 +343,19 @@ Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts` (`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`. +**Always build with `pnpm build:e2e`, never `pnpm run build`.** The mock Tauri +bridge is compiled in only for `--mode e2e` (see `installE2eBridgeIfConfigured` +in `desktop/src/main.tsx`). A plain `pnpm run build` strips it, so +`window.__TAURI_INTERNALS__` is never defined and **every** mock-mode spec fails +with `Cannot read properties of undefined (reading 'invoke')` — the app renders +"Community connection failed" instead of the UI under test. That looks exactly +like a product bug rather than a build mistake, so it burns real time. +`pnpm test:e2e:smoke` and `pnpm test:e2e:integration` run the right build for +you; prefer them over a manual build plus `playwright test`. + **Stale server:** `reuseExistingServer: true` means a previous build's server -serves old code. Kill port 4173 and `pnpm run build` before re-running tests -after code changes. +serves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running +tests after code changes. **`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding) must run BEFORE `installMockBridge(page)` — React reads state on mount, the diff --git a/CHANGELOG.md b/CHANGELOG.md index 12239365046..cfd3b16d0aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## v0.5.0 + +- feat(invites): add use-limited invite links ([#3141](https://github.com/block/buzz/pull/3141)) ([`d500c2d5c`](https://github.com/block/buzz/commit/d500c2d5cf5d9aabe0ca4ebebfcafdbe5f5b7fd3)) +- fix(node): bump Buzz-supplied Node runtimes past OpenClaw's >=24.15.0 floor ([#3218](https://github.com/block/buzz/pull/3218)) ([`98a7b1334`](https://github.com/block/buzz/commit/98a7b1334823ee0be3e3fa5cab7a2e349e438dab)) +- fix(desktop): preserve thread anchor through layout reflow ([#3212](https://github.com/block/buzz/pull/3212)) ([`9810d8545`](https://github.com/block/buzz/commit/9810d8545937329f229ff40d8a19edc9e3e325c1)) +- feat(search): parse from:/in:/after:/before: and pass them in the filter ([#2871](https://github.com/block/buzz/pull/2871)) ([`cb2a265b5`](https://github.com/block/buzz/commit/cb2a265b5399426e808461c1a16713754c593258)) +- fix(desktop): fetch join policies through native networking ([#2862](https://github.com/block/buzz/pull/2862)) ([`0019f8076`](https://github.com/block/buzz/commit/0019f80765e96f056e81b57789b8b5fb80936f72)) +- fix(desktop): republish agent identity records when a persona rename propagates ([#2607](https://github.com/block/buzz/pull/2607)) ([`7ca0bbd94`](https://github.com/block/buzz/commit/7ca0bbd946fd82a7008132f94d069a97bb53f94b)) +- fix(desktop): keep project Inbox previews compact ([#3193](https://github.com/block/buzz/pull/3193)) ([`de1396050`](https://github.com/block/buzz/commit/de13960505fd798070e177cb33b1663100ac06bb)) +- Inbox refactor ([#2045](https://github.com/block/buzz/pull/2045)) ([`2bd4c24b7`](https://github.com/block/buzz/commit/2bd4c24b71335e7ce272ec6de6491f7f37f4b20d)) +- Fix composer selection formatting and drop overlay ([#3172](https://github.com/block/buzz/pull/3172)) ([`99da5b7eb`](https://github.com/block/buzz/commit/99da5b7ebb19e26453e075bfb949672122b31be3)) +- Refine pending message status ([#3153](https://github.com/block/buzz/pull/3153)) ([`75588eaff`](https://github.com/block/buzz/commit/75588eaff2354d620e554c055b80ec83735ddb0a)) +- fix(desktop): recover full local storage on startup ([#3182](https://github.com/block/buzz/pull/3182)) ([`174c38e4b`](https://github.com/block/buzz/commit/174c38e4bd1ed8498641546bc4fcb6d5a4c9cede)) +- fix(desktop): keep collapsed table separators out of spoilers ([#3169](https://github.com/block/buzz/pull/3169)) ([`4d8b676bb`](https://github.com/block/buzz/commit/4d8b676bb283a1917cec5850c3b7327fe122b0c1)) +- feat(desktop): redesign agent runtime settings ([#3093](https://github.com/block/buzz/pull/3093)) ([`d98da7389`](https://github.com/block/buzz/commit/d98da7389e60cfbd79b219aa411449fe2e53a18a)) +- fix(desktop): use forward slashes for git credential.helper on Windows ([#3023](https://github.com/block/buzz/pull/3023)) ([`899531684`](https://github.com/block/buzz/commit/8995316844f7ad50552fbae67fbd35119262796f)) +- chore(desktop): add AgentCreationPreview file-size override to unblock main CI ([#3154](https://github.com/block/buzz/pull/3154)) ([`b92a1f4bf`](https://github.com/block/buzz/commit/b92a1f4bf400e7da5ab7a010cdd81a69497d8191)) +- fix(desktop): make the test loader work on Windows ([#2758](https://github.com/block/buzz/pull/2758)) ([`8bb43d519`](https://github.com/block/buzz/commit/8bb43d51912894553f2670b2d285a96cf09cd472)) +- fix(desktop): make lint and unit-test gates work on Windows ([#2943](https://github.com/block/buzz/pull/2943)) ([`545bb46b8`](https://github.com/block/buzz/commit/545bb46b824a3fbf4401062f03b72531d832ebb9)) +- feat(desktop): add search to agent emoji picker ([#2630](https://github.com/block/buzz/pull/2630)) ([`313f793c8`](https://github.com/block/buzz/commit/313f793c8753d413c22ff8edfe420d5ee78708bc)) +- fix(desktop): keep identity key help dialog readable in dark mode ([#2854](https://github.com/block/buzz/pull/2854)) ([`be275cfc6`](https://github.com/block/buzz/commit/be275cfc6c7b80fe43e9d66c6d14b6d2bbe58a10)) +- feat(acp): title agent sessions from the agent and channel name ([#3028](https://github.com/block/buzz/pull/3028)) ([`f2fe3b63c`](https://github.com/block/buzz/commit/f2fe3b63c21be55907175715c076cd3a9195b74d)) +- feat(git): use agent display name as git author name ([#3040](https://github.com/block/buzz/pull/3040)) ([`18eef633d`](https://github.com/block/buzz/commit/18eef633d88ac465c61d98f12655fbf51dc3ca44)) +- fix(deps): bump nostr to 0.44.6 for RUSTSEC-2026-0216 (NIP-44 remote DoS) ([#3135](https://github.com/block/buzz/pull/3135)) ([`31e2de196`](https://github.com/block/buzz/commit/31e2de1966672e73e026af3c54f3a1a9a2f5e103)) +- fix(desktop): read the newest pair-scoped harness log ([#3134](https://github.com/block/buzz/pull/3134)) ([`654f38490`](https://github.com/block/buzz/commit/654f384906b5c720a60a199d85031a6f1cb6efc9)) +- feat(desktop): handle project work from Inbox ([#3117](https://github.com/block/buzz/pull/3117)) ([`c5c4f390b`](https://github.com/block/buzz/commit/c5c4f390b6713256e2efb8394c59823ebad73db6)) +- fix(desktop): clarify identity key button when key exists ([#2357](https://github.com/block/buzz/pull/2357)) ([`87b3fcd3c`](https://github.com/block/buzz/commit/87b3fcd3c0131683569dd4268b099d18b25dcd5e)) +- Restore Goose and Buzz Agent to onboarding harness selection ([#2731](https://github.com/block/buzz/pull/2731)) ([`7fc0cc82d`](https://github.com/block/buzz/commit/7fc0cc82db4d9dced9c258bbe8b530164a832a77)) +- fix(desktop): render rich project work item content ([#3100](https://github.com/block/buzz/pull/3100)) ([`afb272bb7`](https://github.com/block/buzz/commit/afb272bb7b8d7d45d7de676fa97dcd5a8eefacc7)) +- feat(acp): bring your own harness (BYOH) — generic ACP runtime seam + settings gallery ([#2773](https://github.com/block/buzz/pull/2773)) ([`95fdf9788`](https://github.com/block/buzz/commit/95fdf978800982389b120c66ff5e766d785419c7)) +- feat(desktop): use collective mesh routing for Auto ([#2825](https://github.com/block/buzz/pull/2825)) ([`16d4ec335`](https://github.com/block/buzz/commit/16d4ec335e210295a9d9f77f36c1e85a18b6814a)) +- fix(desktop): strip legacy baked team instructions from stored prompts ([#3035](https://github.com/block/buzz/pull/3035)) ([`aee631448`](https://github.com/block/buzz/commit/aee63144843854ee32ed9d36a2e7511c82ddc6b0)) +- feat(agents): lower default agent parallelism from 24 to 10 ([#3038](https://github.com/block/buzz/pull/3038)) ([`5d8ede446`](https://github.com/block/buzz/commit/5d8ede446f8fdc48146fe56d389cab6bf3500f92)) +- Polish community rail and mobile pairing ([#2972](https://github.com/block/buzz/pull/2972)) ([`e6c90bb7c`](https://github.com/block/buzz/commit/e6c90bb7c430d1b2af16508b634f9a5283b7fa3b)) +- fix(desktop): remove bundled libsystemd from AppImage ([#2353](https://github.com/block/buzz/pull/2353)) ([`a31fc4d2f`](https://github.com/block/buzz/commit/a31fc4d2f35d51cdf45ff8c61fc3a07f49c665e8)) +- fix(desktop): make agent definition authoritative for model/provider/prompt ([#1968](https://github.com/block/buzz/pull/1968)) ([`8c0e8cb16`](https://github.com/block/buzz/commit/8c0e8cb1656b04ad269bce3c2deeda2a943ae78a)) +- chore(desktop): delete dead persona catalog UI cluster ([#2886](https://github.com/block/buzz/pull/2886)) ([`8e67cf399`](https://github.com/block/buzz/commit/8e67cf399d0291bcdbc69cd0402983ca030f05bb)) +- fix(desktop): surface install failures hidden by curl-pipe exit codes ([#2892](https://github.com/block/buzz/pull/2892)) ([`166c6655e`](https://github.com/block/buzz/commit/166c6655e8bca87d83ad60c087fb70a32a026baf)) +- Refactor managed-agent runtime into cohesive modules ([#2974](https://github.com/block/buzz/pull/2974)) ([`74b63e184`](https://github.com/block/buzz/commit/74b63e1846212af6e6751a62cfc631f74b1dfe07)) +- fix(desktop): make Linux AppImage GStreamer work on non-Debian distros ([#2176](https://github.com/block/buzz/pull/2176)) ([`cc6c4d347`](https://github.com/block/buzz/commit/cc6c4d3471629fad018bcf645f9471a01b9ffe2f)) +- refactor(desktop): remove Agent directory section from Agents page ([#2290](https://github.com/block/buzz/pull/2290)) ([`5d1233e84`](https://github.com/block/buzz/commit/5d1233e841b0efa91470bb45467b2c8e4284ebf6)) +- fix(desktop): enable arboard Wayland backend so Linux copies reach the Wayland clipboard ([#2904](https://github.com/block/buzz/pull/2904)) ([`ab7aa8b12`](https://github.com/block/buzz/commit/ab7aa8b1200710dbc2d7a8661ed5aab95c4199c1)) +- fix(desktop): supervise and re-arm relay-mesh runtime ([#2823](https://github.com/block/buzz/pull/2823)) ([`aa51dab9d`](https://github.com/block/buzz/commit/aa51dab9da5fef7054d03cf1a1207986d0000684)) +- fix(agents): run live Databricks discovery instead of the fallback list ([#2890](https://github.com/block/buzz/pull/2890)) ([`8eb6e3eb6`](https://github.com/block/buzz/commit/8eb6e3eb601174249642373a6a367262fa476753)) +- fix(desktop): retire prepend mode on every reader wheel ([#2913](https://github.com/block/buzz/pull/2913)) ([`07d0265cf`](https://github.com/block/buzz/commit/07d0265cfc212ef02e1c26153bf58ff46ce5ffe6)) +- fix(desktop): consolidate prepend scroll correction ([#2855](https://github.com/block/buzz/pull/2855)) ([`25e7864b3`](https://github.com/block/buzz/commit/25e7864b35f4dfd1c0ff31304a38555230a85f8d)) +- fix(desktop): track concurrent agent turns up to the harness maximum ([#2882](https://github.com/block/buzz/pull/2882)) ([`20bff5910`](https://github.com/block/buzz/commit/20bff591023daffc5ee1032cff02b54b75da3567)) +- fix(relay): preserve reconnect backoff ([#2759](https://github.com/block/buzz/pull/2759)) ([`499c5d349`](https://github.com/block/buzz/commit/499c5d349dab13bc906b1af5fe1fcb09ce2afa81)) +- refactor(relay): expose reconnect timing policy ([#2310](https://github.com/block/buzz/pull/2310)) ([`2f0041595`](https://github.com/block/buzz/commit/2f0041595d72529c06885680d2bd07ddb6a0beb4)) +- fix(desktop): clear stale working badges on agent stop/restart ([#2803](https://github.com/block/buzz/pull/2803)) ([`a64cc71f6`](https://github.com/block/buzz/commit/a64cc71f6c1605279b1a6fbd0fe904a2984cbdb0)) +- fix(desktop): surface agent rename relay profile sync failure as a warning toast ([#2279](https://github.com/block/buzz/pull/2279)) ([`5e3d2e484`](https://github.com/block/buzz/commit/5e3d2e4849c0f2512330801d804fb96f4ab72d28)) +- fix(discovery): inject PATH into Codex adapter planning ([#2767](https://github.com/block/buzz/pull/2767)) ([`6ab3835f3`](https://github.com/block/buzz/commit/6ab3835f3fe89ee215819fe8d193463c0ae7472b)) + + ## v0.4.26 - Style mobile pairing QR codes ([#2775](https://github.com/block/buzz/pull/2775)) ([`50655ac09`](https://github.com/block/buzz/commit/50655ac097fbf1a7db1a5284dccc7e2a0b0f1bfc)) diff --git a/Cargo.lock b/Cargo.lock index 9ffea4a92d1..3b60dc4579f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,6 +925,7 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ + "base64", "chrono", "hex", "hmac 0.13.0", @@ -949,6 +950,7 @@ dependencies = [ "chrono", "hex", "nostr", + "rand 0.10.1", "serde", "serde_json", "sha2 0.11.0", @@ -3039,11 +3041,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if 1.0.4", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -6823,15 +6823,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash", "rustls", diff --git a/Cargo.toml b/Cargo.toml index 3499285f917..3ac7ee4cce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,7 @@ reqwest = { version = "0.13", features = ["json", "rustls"], default-features = sha2 = "0.11" hex = "0.4" hmac = "0.13" +base64 = "0.22" # Randomness rand = "0.10" diff --git a/Dockerfile b/Dockerfile index 661be6c3a7c..d883ac6b015 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,9 @@ RUN apt-get update \ ca-certificates \ git \ && rm -rf /var/lib/apt/lists/* +# Keep enough DWARF for native profilers to resolve optimized code to source +# locations. The normal runtime strips it below; runtime-debug retains it. +ENV CARGO_PROFILE_RELEASE_DEBUG=line-tables-only COPY --from=planner /build/recipe.json recipe.json # Cook the full workspace recipe — relay deps include workspace siblings, so # scoping to -p buzz-relay misses transitive deps and re-builds them later. @@ -66,8 +69,12 @@ RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ - -p buzz-pair-relay --bin buzz-pair-relay \ - && strip target/release/buzz-relay \ + -p buzz-pair-relay --bin buzz-pair-relay + +# Derive the normal release binaries from the same optimized ELF files as the +# debug image so the two variants cannot drift at code-generation time. +FROM builder AS stripped-binaries +RUN strip target/release/buzz-relay \ && strip target/release/buzz-admin \ && strip target/release/buzz-pair-relay @@ -111,8 +118,8 @@ COPY web/ web/ COPY admin-web/ admin-web/ RUN pnpm -C web build && pnpm -C admin-web build -# ─── Stage 5: runtime ─────────────────────────────────────────────────────── -FROM debian:${DEBIAN_VERSION}-slim AS runtime +# ─── Stage 5: shared runtime ──────────────────────────────────────────────── +FROM debian:${DEBIAN_VERSION}-slim AS runtime-base # OCI annotations: required for GHCR to auto-link the image to this repo and # inherit its visibility. org.opencontainers.image.source is the load-bearing @@ -135,9 +142,6 @@ RUN apt-get update \ && useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \ --create-home --shell /usr/sbin/nologin buzz -COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay -COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin -COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay COPY --from=web-builder /build/web/dist /srv/buzz/web COPY --from=web-builder /build/admin-web/dist /srv/buzz/admin-web @@ -157,3 +161,18 @@ USER buzz:buzz WORKDIR /var/lib/buzz ENTRYPOINT ["/usr/local/bin/buzz-relay"] + +# Optimized binaries with line-table debug information for native profiling. +# Published under debug-* tags; runtime behavior otherwise matches the normal +# image exactly. +FROM runtime-base AS runtime-debug +COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay +COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin +COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay + +# Keep the stripped runtime as the final/default Dockerfile target so existing +# `docker build .` callers and release tags retain their current behavior. +FROM runtime-base AS runtime +COPY --from=stripped-binaries /build/target/release/buzz-relay /usr/local/bin/buzz-relay +COPY --from=stripped-binaries /build/target/release/buzz-admin /usr/local/bin/buzz-admin +COPY --from=stripped-binaries /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay diff --git a/Justfile b/Justfile index bcef8983bcc..4a8f8d504c6 100644 --- a/Justfile +++ b/Justfile @@ -92,7 +92,11 @@ build-release: cargo build --workspace --release # Run repo lint and formatting checks -check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check +check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check docker-tag-matrix-check + +# Verify Docker release/debug tag generation cannot double-apply a variant prefix +docker-tag-matrix-check: + ./scripts/test-docker-tag-matrix.sh # Format all Rust code fmt: diff --git a/RELEASING.md b/RELEASING.md index 9785122aadd..063b813e2ce 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -57,10 +57,13 @@ or mobile GitHub Release. 2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `relay-v`. 3. **The tag triggers `docker.yml`.** Stable releases update the version - aliases and `latest`; prereleases do not. + aliases and `latest`; prereleases do not. Each release also publishes an + optimized, symbol-bearing image under matching `debug-` tags (for example, + `debug-0.3.0` and `debug-latest`) for native profiling. The ordinary tags + remain stripped and are the default for deployments that do not need it. Every push to `main` continues to publish the rolling relay `:main` and -`:sha-<7>` tags. +`:sha-<7>` tags, plus matching `:debug-main` and `:debug-sha-<7>` variants. ### Mobile diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index 78aa5acea95..4a3bd633449 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -7,7 +7,12 @@ import { useState, } from "react"; import { ApiFailure, request } from "./api"; -import type { FeedbackDetail, FeedbackSummary, Report } from "./types"; +import type { + FeedbackDetail, + FeedbackSummary, + Report, + ReportDetail as ReportDetailData, +} from "./types"; import { useResource } from "./useResource"; function usePath() { @@ -131,7 +136,10 @@ function Reports() { } function ReportDetail({ id }: { id: string }) { - const resource = useResource(() => request(`/reports/${id}`), id); + const resource = useResource( + () => request(`/reports/${id}`), + id, + ); return ( {report.target} + {report.targetKind === "event" ? ( + <> +
Message
+
+ {report.message ? ( +
+ {report.message.deletedAt ? ( + Deleted + ) : null} +

{report.message.content}

+
+ Author + {report.message.authorPubkey} + Created + +
+
+ ) : ( +

+ Message content is unavailable. It may have expired or + been removed from event storage. +

+ )} +
+ + ) : null}
Note
{report.note ?? "No note provided."} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 9eb53975667..00df4b922b0 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -500,6 +500,38 @@ dd { margin: 0; } +.reported-message { + display: grid; + justify-items: start; + gap: 1rem; + border-left: 3px solid #d7d72e; + border-radius: 0 0.8rem 0.8rem 0; + background: #f6f6f1; + padding: 1rem; +} + +.reported-message p, +.message-unavailable { + margin: 0; + overflow-wrap: anywhere; + line-height: 1.5; + white-space: pre-wrap; +} + +.reported-message-meta { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.4rem 0.75rem; + width: 100%; + color: rgb(35 30 30 / 48%); + font-size: 0.78rem; +} + +.message-unavailable { + color: rgb(35 30 30 / 60%); + font-style: italic; +} + .sensitive { border-left: 3px solid #2e387d; border-radius: 0 0.8rem 0.8rem 0; diff --git a/admin-web/src/types.ts b/admin-web/src/types.ts index 1aab0cb9ac8..6c108377589 100644 --- a/admin-web/src/types.ts +++ b/admin-web/src/types.ts @@ -12,6 +12,17 @@ export interface Report { createdAt: string; } +export interface ReportedMessage { + authorPubkey: string; + content: string; + createdAt: string; + deletedAt: string | null; +} + +export interface ReportDetail extends Report { + message: ReportedMessage | null; +} + export interface FeedbackSummary { id: string; communityId: string; diff --git a/admin-web/tests/routes.spec.ts b/admin-web/tests/routes.spec.ts index 002ce456b39..3c965dd2d85 100644 --- a/admin-web/tests/routes.spec.ts +++ b/admin-web/tests/routes.spec.ts @@ -58,6 +58,72 @@ test("report rows render the relay response contract", async ({ page }) => { await expect(page.getByText("Unknown date")).toHaveCount(0); }); +test("event report detail renders the reported message content", async ({ + page, +}) => { + const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a08"; + await page.route(`**/api/admin/v1/reports/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc", + communityHost: "design.buzz.xyz", + reporterPubkey: "21".repeat(32), + targetKind: "event", + target: "12".repeat(32), + reportType: "spam", + status: "open", + createdAt: "2026-07-17T17:30:00Z", + message: { + authorPubkey: "31".repeat(32), + content: + "This is the complete reported message.\nIt preserves lines.", + createdAt: "2026-07-17T17:25:00Z", + deletedAt: null, + }, + }), + }), + ); + + await page.goto(`/reports/${id}`); + await expect( + page.getByText("This is the complete reported message.", { exact: false }), + ).toBeVisible(); + await expect(page.getByText("31".repeat(32))).toBeVisible(); + await expect( + page.getByText("Message content is unavailable", { exact: false }), + ).toHaveCount(0); +}); + +test("event report detail explains when message content is unavailable", async ({ + page, +}) => { + const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a09"; + await page.route(`**/api/admin/v1/reports/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc", + communityHost: "design.buzz.xyz", + reporterPubkey: "21".repeat(32), + targetKind: "event", + target: "12".repeat(32), + reportType: "spam", + status: "open", + createdAt: "2026-07-17T17:30:00Z", + message: null, + }), + }), + ); + + await page.goto(`/reports/${id}`); + await expect( + page.getByText("Message content is unavailable", { exact: false }), + ).toBeVisible(); +}); + test("feedback cards open the complete submission", async ({ page }) => { const id = "feed0000-0000-4000-8000-000000000001"; const fullBody = `${"Long feedback ".repeat(30)}end of feedback`; diff --git a/bin/.node-24.14.0.pkg b/bin/.node-24.15.0.pkg similarity index 100% rename from bin/.node-24.14.0.pkg rename to bin/.node-24.15.0.pkg diff --git a/bin/corepack b/bin/corepack index 07e8834efbf..4df063726ce 120000 --- a/bin/corepack +++ b/bin/corepack @@ -1 +1 @@ -.node-24.14.0.pkg \ No newline at end of file +.node-24.15.0.pkg \ No newline at end of file diff --git a/bin/node b/bin/node index 07e8834efbf..4df063726ce 120000 --- a/bin/node +++ b/bin/node @@ -1 +1 @@ -.node-24.14.0.pkg \ No newline at end of file +.node-24.15.0.pkg \ No newline at end of file diff --git a/bin/npm b/bin/npm index 07e8834efbf..4df063726ce 120000 --- a/bin/npm +++ b/bin/npm @@ -1 +1 @@ -.node-24.14.0.pkg \ No newline at end of file +.node-24.15.0.pkg \ No newline at end of file diff --git a/bin/npx b/bin/npx index 07e8834efbf..4df063726ce 120000 --- a/bin/npx +++ b/bin/npx @@ -1 +1 @@ -.node-24.14.0.pkg \ No newline at end of file +.node-24.15.0.pkg \ No newline at end of file diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 49e4d6cce0c..c0147baf1b3 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -1854,7 +1854,8 @@ pub enum ModelSwitchMethod { /// Extract `configOptions` entries with `category == "model"` from a `session/new` result. /// -/// Returns the raw JSON array entries. Each entry has `configId`, `displayName`, +/// Returns the raw JSON array entries. Each entry has `configId` (spelled `id` +/// by some adapters, e.g. claude-agent-acp), `displayName`, /// `options: [{ value, displayName }]`, etc. pub fn extract_model_config_options(result: &serde_json::Value) -> Vec { result["configOptions"] @@ -1888,7 +1889,14 @@ pub fn resolve_model_switch_method( // 1. Search stable configOptions for a "model"-category entry whose // options contain a value matching desired_model. for config_opt in extract_model_config_options(session_new_result) { - let config_id = match config_opt.get("configId").and_then(|v| v.as_str()) { + // Adapters disagree on the key: the ACP spec says `configId`, but + // claude-agent-acp emits `id`. Accept both; the set request always + // uses `configId` on the wire. + let config_id = match config_opt + .get("configId") + .or_else(|| config_opt.get("id")) + .and_then(|v| v.as_str()) + { Some(id) => id, None => continue, }; @@ -2464,6 +2472,36 @@ mod tests { ); } + #[test] + fn resolve_accepts_id_keyed_config_options() { + // claude-agent-acp (observed on v0.61.0) keys config options with + // `id` instead of the spec's `configId`. Payload mirrors its real + // `session/new` response. + let result = serde_json::json!({ + "configOptions": [{ + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "default", + "options": [ + { "value": "default", "name": "Default" }, + { "value": "opus[1m]", "name": "Opus" }, + { "value": "sonnet", "name": "Sonnet" } + ] + }], + "models": null + }); + let method = super::resolve_model_switch_method(&result, "opus[1m]"); + assert_eq!( + method, + Some(super::ModelSwitchMethod::ConfigOption { + config_id: "model".to_string(), + option_value: "opus[1m]".to_string(), + }) + ); + } + #[test] fn resolve_falls_back_to_unstable() { let result = serde_json::json!({ diff --git a/crates/buzz-core/Cargo.toml b/crates/buzz-core/Cargo.toml index 489df360dd5..c55225adf60 100644 --- a/crates/buzz-core/Cargo.toml +++ b/crates/buzz-core/Cargo.toml @@ -11,6 +11,7 @@ description = "Core types, event verification, and filter matching for Buzz" test-utils = [] [dependencies] +base64 = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-core/src/invite.rs b/crates/buzz-core/src/invite.rs new file mode 100644 index 00000000000..8f557c95aa2 --- /dev/null +++ b/crates/buzz-core/src/invite.rs @@ -0,0 +1,109 @@ +//! Shared pure contracts for relay invite links. +//! +//! The relay transport and database persistence layers both depend on +//! `buzz-core`. Protocol constants and deterministic v2 code operations live +//! here so neither layer becomes the accidental source of truth. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use sha2::{Digest, Sha256}; + +/// Minimum invite lifetime accepted by the mint API: 60 seconds. +pub const MIN_INVITE_TTL_SECS: u64 = 60; + +/// Default invite lifetime when the mint request omits `ttl_secs`: 72 hours. +pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60; + +/// Maximum invite lifetime accepted by the mint API: 30 days. +pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60; + +/// Maximum supported `max_uses` value. Matches the database constraint. +pub const MAX_INVITE_USES: i32 = 10_000; + +/// Prefix that distinguishes v2 opaque database-backed codes from v1 tokens. +pub const V2_PREFIX: &str = "v2."; + +/// Number of random bytes encoded in a v2 invite code. +pub const V2_SECRET_LEN: usize = 32; + +/// A malformed v2 opaque invite code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidV2InviteCode; + +/// Build a canonical v2 code from its random secret. +pub fn encode_v2_code(secret: &[u8; V2_SECRET_LEN]) -> String { + format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret)) +} + +/// Validate the canonical v2 opaque-code shape without consulting storage. +/// +/// A valid code is exactly `v2.` followed by the unpadded base64url encoding +/// of a 32-byte secret. The decode/re-encode comparison rejects aliases such +/// as padded or otherwise non-canonical encodings. +pub fn validate_v2_code(code: &str) -> Result<(), InvalidV2InviteCode> { + let encoded = code.strip_prefix(V2_PREFIX).ok_or(InvalidV2InviteCode)?; + let secret = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| InvalidV2InviteCode)?; + if secret.len() != V2_SECRET_LEN || URL_SAFE_NO_PAD.encode(&secret) != encoded { + return Err(InvalidV2InviteCode); + } + Ok(()) +} + +/// Hash the complete v2 code to the digest persisted by the database. +pub fn hash_v2_code(code: &str) -> [u8; 32] { + Sha256::digest(code.as_bytes()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v2_code_round_trip_is_canonical() { + let secret = [7_u8; V2_SECRET_LEN]; + let code = encode_v2_code(&secret); + + assert_eq!(validate_v2_code(&code), Ok(())); + assert_eq!( + code, + format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret)) + ); + } + + #[test] + fn v2_validation_rejects_malformed_and_noncanonical_codes() { + let valid = encode_v2_code(&[7_u8; V2_SECRET_LEN]); + let short = format!( + "{V2_PREFIX}{}", + URL_SAFE_NO_PAD.encode([7_u8; V2_SECRET_LEN - 1]) + ); + + for malformed in [ + "v2.", + "v2.not-base64!", + short.as_str(), + &format!("{valid}="), + ] { + assert_eq!( + validate_v2_code(malformed), + Err(InvalidV2InviteCode), + "accepted malformed v2 code: {malformed}" + ); + } + } + + #[test] + fn v2_hash_covers_the_complete_code() { + let code = encode_v2_code(&[7_u8; V2_SECRET_LEN]); + + let expected: [u8; 32] = Sha256::digest(code.as_bytes()).into(); + + assert_eq!(hash_v2_code(&code), expected); + assert_ne!( + hash_v2_code(&code), + hash_v2_code(code.trim_start_matches(V2_PREFIX)) + ); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 84aaa0bd15a..b654e37c414 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -22,6 +22,8 @@ pub mod event; pub mod filter; /// Git permission types — ref patterns, protection rules, policy evaluation. pub mod git_perms; +/// Shared invite-link contract constants. +pub mod invite; /// Buzz kind number registry — custom event type constants. pub mod kind; /// Network utilities — SSRF-safe IP classification. diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 23ea3c9d8fe..01f1e172b6e 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -20,6 +20,7 @@ sha2 = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } nostr = { workspace = true } +rand = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/buzz-db/src/admin_moderation.rs b/crates/buzz-db/src/admin_moderation.rs index 2a0d2768947..31efaca3623 100644 --- a/crates/buzz-db/src/admin_moderation.rs +++ b/crates/buzz-db/src/admin_moderation.rs @@ -54,6 +54,31 @@ pub struct AdminReport { pub created_at: DateTime, } +/// Reported message details available only on the admin report detail read. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportedMessage { + /// Message author public key. + pub author_pubkey: String, + /// Complete message content. + pub content: String, + /// Timestamp signed into the message event. + pub created_at: DateTime, + /// Soft-deletion time, when the message has since been deleted. + pub deleted_at: Option>, +} + +/// Deployment-global moderation report detail. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportDetail { + /// Report metadata. + #[serde(flatten)] + pub report: AdminReport, + /// Reported message when the report targets a stored event. + pub message: Option, +} + /// Deployment-global product feedback with source-community provenance. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -128,24 +153,54 @@ pub async fn list_reports( rows.into_iter().map(row_to_report).collect() } -/// Fetch one report globally by its row id. -pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result> { +/// Fetch one report globally by its row id, including its event target content. +pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result> { let row = sqlx::query( r#" SELECT r.id, r.community_id, c.host AS community_host, r.report_event_id, r.reporter_pubkey, r.target_kind, r.target_event_id, r.target_pubkey, r.target_blob_sha256, r.channel_id, r.report_type, r.note, r.status, r.resolved_by, - r.resolved_at, r.action_id, r.created_at + r.resolved_at, r.action_id, r.created_at, + target.pubkey AS message_author_pubkey, + target.content AS message_content, + target.created_at AS message_created_at, + target.deleted_at AS message_deleted_at FROM moderation_reports r JOIN communities c ON c.id = r.community_id + LEFT JOIN LATERAL ( + SELECT e.pubkey, e.content, e.created_at, e.deleted_at + FROM events e + WHERE r.target_kind = 'event' + AND e.community_id = r.community_id + AND e.id = r.target_event_id + ORDER BY e.created_at DESC + LIMIT 1 + ) target ON TRUE WHERE r.id = $1 "#, ) .bind(report_id) .fetch_optional(pool) .await?; - row.map(row_to_report).transpose() + row.map(|row| { + let message = row + .try_get::>, _>("message_author_pubkey")? + .map(|author_pubkey| -> Result { + Ok(AdminReportedMessage { + author_pubkey: hex::encode(author_pubkey), + content: row.try_get("message_content")?, + created_at: row.try_get("message_created_at")?, + deleted_at: row.try_get("message_deleted_at")?, + }) + }) + .transpose()?; + Ok(AdminReportDetail { + report: row_to_report(row)?, + message, + }) + }) + .transpose() } fn row_to_report(row: sqlx::postgres::PgRow) -> Result { @@ -228,3 +283,206 @@ fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result { received_at: row.try_get("received_at")?, }) } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn insert_community(pool: &PgPool, label: &str) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("admin-report-{label}-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn insert_event( + pool: &PgPool, + community_id: Uuid, + event_id: &[u8], + author: &[u8], + content: &str, + deleted_at: Option>, + ) { + sqlx::query( + r#" + INSERT INTO events ( + community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at + ) VALUES ($1, $2, $3, $4, 9, '[]'::jsonb, $5, $6, $7) + "#, + ) + .bind(community_id) + .bind(event_id) + .bind(author) + .bind(Utc::now()) + .bind(content) + .bind(vec![3_u8; 64]) + .bind(deleted_at) + .execute(pool) + .await + .expect("insert event"); + } + + async fn insert_event_report( + pool: &PgPool, + community_id: Uuid, + target_event_id: &[u8], + ) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, id, report_event_id, reporter_pubkey, + target_kind, target_event_id, report_type + ) VALUES ($1, $2, $3, $4, 'event', $5, 'spam') + "#, + ) + .bind(community_id) + .bind(id) + .bind(Uuid::new_v4().as_bytes().repeat(2)) + .bind(vec![4_u8; 32]) + .bind(target_event_id) + .execute(pool) + .await + .expect("insert report"); + id + } + + async fn insert_pubkey_report(pool: &PgPool, community_id: Uuid) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, id, report_event_id, reporter_pubkey, + target_kind, target_pubkey, report_type + ) VALUES ($1, $2, $3, $4, 'pubkey', $5, 'spam') + "#, + ) + .bind(community_id) + .bind(id) + .bind(Uuid::new_v4().as_bytes().repeat(2)) + .bind(vec![4_u8; 32]) + .bind(vec![7_u8; 32]) + .execute(pool) + .await + .expect("insert report"); + id + } + + async fn delete_report_fixture(pool: &PgPool, community_id: Uuid) { + sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1") + .bind(community_id) + .execute(pool) + .await + .expect("delete report fixture"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(pool) + .await + .expect("delete community fixture"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_reads_only_the_same_community_target_and_includes_deleted_content() { + let pool = setup_pool().await; + let report_community = insert_community(&pool, "reported").await; + let other_community = insert_community(&pool, "other").await; + let event_id = vec![1_u8; 32]; + let deleted_at = Utc::now(); + insert_event( + &pool, + report_community, + &event_id, + &[5_u8; 32], + "reported message", + Some(deleted_at), + ) + .await; + insert_event( + &pool, + other_community, + &event_id, + &[6_u8; 32], + "wrong tenant message", + None, + ) + .await; + let report_id = insert_event_report(&pool, report_community, &event_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + let message = detail.message.expect("reported message exists"); + assert_eq!(message.content, "reported message"); + assert_eq!(message.author_pubkey, hex::encode([5_u8; 32])); + assert!(message.deleted_at.is_some()); + + sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1") + .bind(report_community) + .execute(&pool) + .await + .expect("delete report fixture"); + sqlx::query("DELETE FROM events WHERE community_id = ANY($1)") + .bind(vec![report_community, other_community]) + .execute(&pool) + .await + .expect("delete event fixtures"); + sqlx::query("DELETE FROM communities WHERE id = ANY($1)") + .bind(vec![report_community, other_community]) + .execute(&pool) + .await + .expect("delete community fixtures"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_has_no_message_for_non_event_target() { + let pool = setup_pool().await; + let community_id = insert_community(&pool, "pubkey-target").await; + let report_id = insert_pubkey_report(&pool, community_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + assert_eq!(detail.report.target_kind, "pubkey"); + assert!(detail.message.is_none()); + + delete_report_fixture(&pool, community_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_has_no_message_when_event_row_is_missing() { + let pool = setup_pool().await; + let community_id = insert_community(&pool, "missing-event").await; + let missing_event_id = vec![8_u8; 32]; + let report_id = insert_event_report(&pool, community_id, &missing_event_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + assert_eq!(detail.report.target_kind, "event"); + assert_eq!(detail.report.target, hex::encode(missing_event_id)); + assert!(detail.message.is_none()); + + delete_report_fixture(&pool, community_id).await; + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 1df60450322..f8650ba3e5c 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -39,6 +39,8 @@ pub mod product_feedback; pub mod push; /// Reaction persistence. pub mod reaction; +/// Use-limited relay invite persistence (v2 opaque tokens). +pub mod relay_invite; /// Relay-level membership persistence (NIP-43). pub mod relay_members; /// Replica freshness fence for keyset-cursor read routing. @@ -563,7 +565,7 @@ impl Db { pub async fn admin_get_report( &self, id: Uuid, - ) -> Result> { + ) -> Result> { admin_moderation::get_report(&self.pool, id).await } @@ -2657,6 +2659,23 @@ impl Db { workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await } + /// Disable all of an owner's workflows in a channel (SEC-006, on + /// membership loss). Returns the number of workflows disabled. + pub async fn disable_workflows_for_owner_in_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + ) -> Result { + workflow::disable_workflows_for_owner_in_channel( + &self.pool, + community_id, + channel_id, + owner_pubkey, + ) + .await + } + /// Delete a workflow and all its runs/approvals. pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { workflow::delete_workflow(&self.pool, community_id, id).await @@ -3046,6 +3065,51 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool, community).await } + /// Mints a v2 use-limited relay invite. The plaintext code is returned + /// exactly once; only its SHA-256 hash is persisted. + /// + /// `max_uses` is `None` for unlimited or `Some(1..=10000)`. + /// `ttl_secs` must be in the shared invite lifetime range. + pub async fn mint_relay_invite( + &self, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, + ) -> Result { + relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await + } + + /// Delete one bounded batch of invites expired before `cutoff`. + pub async fn reap_expired_relay_invites( + &self, + cutoff: chrono::DateTime, + ) -> Result { + relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await + } + + /// Atomically claims a v2 relay invite. The full redemption (membership + /// insert, policy evidence, use_count increment) runs in one PostgreSQL + /// transaction with `FOR UPDATE` on the invite row. + /// + /// `token_hash` is the SHA-256 of the presented v2 code (32 bytes). + pub async fn claim_relay_invite( + &self, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + ) -> Result { + relay_invite::claim_relay_invite( + &self.pool, + community, + token_hash, + claimer_pubkey, + policy_version, + ) + .await + } + /// Sidecar an accepted product-feedback event, idempotent by event id. pub async fn insert_product_feedback( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index eb70413057b..6a73acf9ab3 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -896,6 +896,31 @@ mod tests { .as_str() .contains("CREATE TABLE relay_invites")); + // Use-limited invite links: durable relay_invites table stores only + // the SHA-256 of an opaque v2 code, scoped by community_id. Never + // listed in _operator_global_tables — it is community-scoped. + assert_eq!(migrations[24].version, 25); + let relay_invites = migrations[24].sql.as_str(); + assert!(relay_invites.contains("CREATE TABLE relay_invites")); + assert!(relay_invites + .contains("token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32)")); + assert!(relay_invites.contains("PRIMARY KEY (community_id, id)")); + assert!(relay_invites.contains("UNIQUE (community_id, token_hash)")); + assert!( + relay_invites.contains("max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000)") + ); + assert!(relay_invites.contains("CHECK (max_uses IS NULL OR use_count <= max_uses)")); + assert!(relay_invites.contains("role = 'member'")); + assert!(relay_invites + .contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)")); + assert!(!relay_invites.contains("_operator_global_tables")); + + let desired_schema = include_str!("../../../schema/schema.sql"); + assert!( + desired_schema.contains("CREATE TABLE join_policy_acceptances"), + "desired-state schema must include join-policy evidence used by invite claims", + ); + assert_eq!(migrations[25].version, 26); assert_eq!(&*migrations[25].description, "users agent owner lookup"); assert!(migrations[25] @@ -1144,7 +1169,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(24)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(25)); } #[tokio::test] diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs new file mode 100644 index 00000000000..82b71b07bb0 --- /dev/null +++ b/crates/buzz-db/src/relay_invite.rs @@ -0,0 +1,671 @@ +//! Use-limited relay invite persistence (v2 opaque tokens). +//! +//! Unlike the stateless v1 HMAC invite tokens in `buzz-relay::invite_token`, +//! v2 invites are backed by durable rows in `relay_invites`. The table stores +//! only `SHA-256(code)` — never the reusable bearer secret — so a leaked +//! database does not immediately yield valid invite codes. +//! +//! Every lookup binds both `(community_id, token_hash)` to prevent cross-tenant +//! authorization seams: a code minted on tenant A presented to tenant B returns +//! `Invalid`, not a membership. +//! +//! ## Atomic redemption +//! +//! `claim_relay_invite` executes the full redemption in one PostgreSQL +//! transaction: `SELECT FOR UPDATE` on the invite row, membership insert, +//! join-policy evidence insert, and `use_count` increment all commit together. +//! `FOR UPDATE` serializes concurrent claims for one invite across relay +//! processes — exactly one claimant can win the final slot. + +use buzz_core::invite::{ + encode_v2_code, hash_v2_code, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, + V2_SECRET_LEN, +}; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row as _}; + +use crate::error::Result; +use crate::CommunityId; + +/// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are +/// typed variants so the relay layer can map them to distinct HTTP responses +/// without inspecting database errors. +#[derive(Debug, PartialEq)] +pub enum ClaimOutcome { + /// A new relay member was inserted. `use_count` is the post-increment count; + /// `uses_remaining` is `None` for unlimited invites. + Joined { + /// Post-claim use count. + use_count: i32, + /// Remaining slots, or `None` when the invite is unlimited. + uses_remaining: Option, + }, + /// The claimer was already a member. `use_count` was NOT incremented. + AlreadyMember { + /// Current use count (unchanged by this claim). + use_count: i32, + /// Remaining slots, or `None` when the invite is unlimited. + uses_remaining: Option, + }, + /// The invite's `expires_at` has passed. + Expired, + /// The invite's use budget is fully consumed. + Exhausted, + /// No invite row matches `(community_id, token_hash)`. + Invalid, +} + +/// A freshly minted v2 invite, including the plaintext code and metadata. +#[derive(Debug)] +pub struct MintedInvite { + /// The full v2 code string (`v2.`). Returned to the caller + /// exactly once; the database stores only the SHA-256 hash. + pub code: String, + /// When the invite expires (UTC). + pub expires_at: DateTime, + /// `None` means unlimited; `Some(n)` means at most `n` uses. + pub max_uses: Option, + /// Remaining uses at mint time (equals `max_uses` when bounded, `None` + /// when unlimited). + pub uses_remaining: Option, + /// The invite's database-generated UUID. + pub invite_id: uuid::Uuid, +} + +fn validate_mint_inputs(ttl_secs: u64, max_uses: Option) -> Result<()> { + if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl_secs) { + return Err(crate::error::DbError::InvalidData(format!( + "ttl_secs must be between {MIN_INVITE_TTL_SECS} and {MAX_INVITE_TTL_SECS}" + ))); + } + + if let Some(max_uses) = max_uses { + if !(1..=MAX_INVITE_USES).contains(&max_uses) { + return Err(crate::error::DbError::InvalidData(format!( + "max_uses must be between 1 and {MAX_INVITE_USES}" + ))); + } + } + + Ok(()) +} + +/// Mint a v2 invite: generate a 32-byte random secret, hash it, persist the +/// row, and return the plaintext code plus metadata. +/// +/// `ttl_secs` must be in the shared invite lifetime range. +/// `max_uses` must be `None` (unlimited) or `Some(1..=10000)`. +pub async fn mint_relay_invite( + pool: &PgPool, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, +) -> Result { + validate_mint_inputs(ttl_secs, max_uses)?; + + // Generate 32 random bytes and encode as base64url — this is the secret. + let secret: [u8; V2_SECRET_LEN] = rand::random(); + let code = encode_v2_code(&secret); + let token_hash = hash_v2_code(&code); + let now = Utc::now(); + let expires_at = now + chrono::Duration::seconds(ttl_secs as i64); + + let row = sqlx::query( + "INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \ + VALUES ($1, $2, $3, $4, $5) \ + RETURNING id", + ) + .bind(community.as_uuid()) + .bind(token_hash.as_slice()) + .bind(max_uses) + .bind(expires_at) + .bind(created_by) + .fetch_one(pool) + .await?; + + let invite_id: uuid::Uuid = row.try_get("id")?; + + Ok(MintedInvite { + code, + expires_at, + max_uses, + uses_remaining: max_uses, + invite_id, + }) +} + +fn log_claim_outcome( + community: CommunityId, + invite_id: Option, + outcome: &'static str, + max_uses: Option, + use_count: Option, +) { + tracing::info!( + community = %community, + invite_id = ?invite_id, + outcome, + max_uses = ?max_uses, + use_count = ?use_count, + "relay invite claim completed" + ); +} + +/// Maximum rows deleted by one retention sweep so cleanup cannot monopolize +/// the invite table on a busy deployment. +const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000; + +/// Delete one bounded batch of invite rows expired before `cutoff`. +/// +/// The relay calls this from its leader-only periodic tick. Ordering by the +/// expiry index makes old rows drain first without turning cleanup into an +/// unbounded transaction. +pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> Result { + let result = sqlx::query( + "DELETE FROM relay_invites \ + WHERE (community_id, id) IN (\ + SELECT community_id, id FROM relay_invites \ + WHERE expires_at < $1 \ + ORDER BY expires_at \ + LIMIT $2\ + )", + ) + .bind(cutoff) + .bind(RETENTION_SWEEP_BATCH_SIZE) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +/// Atomically claim a v2 relay invite. +/// +/// Executes the full redemption in one PostgreSQL transaction: +/// 1. Hash the presented code. +/// 2. `SELECT ... FOR UPDATE` on the invite row scoped by `(community, token_hash)`. +/// 3. If no row → `Invalid`. +/// 4. If `expires_at <= now()` → `Expired`. +/// 5. Check existing membership. +/// 6. If already a member → insert policy evidence (if configured), commit, +/// return `AlreadyMember` (no increment). +/// 7. If `max_uses` is set and `use_count >= max_uses` → `Exhausted`. +/// 8. Insert relay member with role `member`, `added_by = 'invite'`. +/// 9. Insert join-policy acceptance evidence (if configured). +/// 10. Increment `use_count`. +/// 11. Commit. +/// +/// `FOR UPDATE` serializes concurrent claims so exactly one claimant wins the +/// final slot. Membership insertion, policy evidence, and consumption share +/// one commit — a failure in any rolls back all. +pub async fn claim_relay_invite( + pool: &PgPool, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, +) -> Result { + let mut tx = pool.begin().await?; + + // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. + let row = sqlx::query( + "SELECT id, max_uses, use_count, expires_at \ + FROM relay_invites \ + WHERE community_id = $1 AND token_hash = $2 \ + FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(token_hash) + .fetch_optional(&mut *tx) + .await?; + + // 3. No matching invite. + let Some(invite) = row else { + tx.rollback().await?; + log_claim_outcome(community, None, "invalid", None, None); + return Ok(ClaimOutcome::Invalid); + }; + + let invite_id: uuid::Uuid = invite.try_get("id")?; + let max_uses: Option = invite.try_get("max_uses")?; + let use_count: i32 = invite.try_get("use_count")?; + let expires_at: DateTime = invite.try_get("expires_at")?; + + // Expiry is checked before membership deliberately. An expired bearer must + // not authorize fresh policy-acceptance evidence, even for an existing + // member; exhausted-but-live invites remain valid for idempotent retries. + if expires_at <= Utc::now() { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "expired", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::Expired); + } + + let uses_remaining = || max_uses.map(|mu| mu - use_count); + + // 5. Check existing membership. + let existing = + sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_some() { + // 6. Already a member — insert policy evidence but do NOT increment. + if let Some(version) = policy_version { + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .bind(version) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + log_claim_outcome( + community, + Some(invite_id), + "already_member", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }); + } + + // 7. Capacity check. + if let Some(mu) = max_uses { + if use_count >= mu { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "exhausted", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::Exhausted); + } + } + + // 8. Insert relay member. The conflict branch covers a claimant admitted + // concurrently through a different invite: only the transaction that + // actually inserted membership may consume this invite. + let inserted = sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'member', 'invite') \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .execute(&mut *tx) + .await? + .rows_affected() + > 0; + + // 9. Insert join-policy acceptance evidence. This is required for both a + // new member and a claimant whose concurrent membership insert won first. + if let Some(version) = policy_version { + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .bind(version) + .execute(&mut *tx) + .await?; + } + + if !inserted { + tx.commit().await?; + log_claim_outcome( + community, + Some(invite_id), + "already_member", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }); + } + + // 10. Increment use_count (for every new member, even unlimited). + let new_use_count = use_count + 1; + sqlx::query("UPDATE relay_invites SET use_count = $1 WHERE community_id = $2 AND id = $3") + .bind(new_use_count) + .bind(community.as_uuid()) + .bind(invite_id) + .execute(&mut *tx) + .await?; + + // 11. Commit. + tx.commit().await?; + + let new_uses_remaining = max_uses.map(|mu| mu - new_use_count); + + log_claim_outcome( + community, + Some(invite_id), + "joined", + max_uses, + Some(new_use_count), + ); + + Ok(ClaimOutcome::Joined { + use_count: new_use_count, + uses_remaining: new_uses_remaining, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay_members::is_relay_member; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("relay-invite-test-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + async fn delete_test_community(pool: &PgPool, community: CommunityId) { + let mut tx = pool.begin().await.expect("begin test cleanup"); + sqlx::query("DELETE FROM relay_invites WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test invites"); + sqlx::query("DELETE FROM relay_members WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test members"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test community"); + tx.commit().await.expect("commit test cleanup"); + } + + fn test_pubkey() -> String { + format!("{:064x}", Uuid::new_v4().as_u128()) + } + + async fn use_count(pool: &PgPool, community: CommunityId, invite_id: Uuid) -> i32 { + sqlx::query_scalar( + "SELECT use_count FROM relay_invites WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(invite_id) + .fetch_one(pool) + .await + .expect("read invite use_count") + } + + #[test] + fn mint_validation_rejects_invalid_bounds_before_database_access() { + for (ttl, max_uses) in [ + (MIN_INVITE_TTL_SECS - 1, None), + (MAX_INVITE_TTL_SECS + 1, None), + (3600, Some(0)), + (3600, Some(-1)), + (3600, Some(MAX_INVITE_USES + 1)), + ] { + let error = validate_mint_inputs(ttl, max_uses).expect_err("invalid mint contract"); + assert!(matches!(error, crate::DbError::InvalidData(_)), "{error:?}"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bounded_claim_exhausts_and_existing_member_retry_does_not_consume() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let first = test_pubkey(); + let second = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + assert_eq!( + claim_relay_invite(&pool, community, &hash, &first, None) + .await + .expect("first claim"), + ClaimOutcome::Joined { + use_count: 1, + uses_remaining: Some(0), + } + ); + assert_eq!( + claim_relay_invite(&pool, community, &hash, &first, None) + .await + .expect("idempotent retry"), + ClaimOutcome::AlreadyMember { + use_count: 1, + uses_remaining: Some(0), + } + ); + assert_eq!( + claim_relay_invite(&pool, community, &hash, &second, None) + .await + .expect("exhausted claim"), + ClaimOutcome::Exhausted + ); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 1); + assert!(is_relay_member(&pool, community, &first) + .await + .expect("first membership")); + assert!(!is_relay_member(&pool, community, &second) + .await + .expect("second membership")); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_claims_serialize_the_final_slot() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let first = test_pubkey(); + let second = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + let (first_outcome, second_outcome) = tokio::join!( + claim_relay_invite(&pool, community, &hash, &first, None), + claim_relay_invite(&pool, community, &hash, &second, None), + ); + let outcomes = [ + first_outcome.expect("first concurrent claim"), + second_outcome.expect("second concurrent claim"), + ]; + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, ClaimOutcome::Joined { .. })) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, ClaimOutcome::Exhausted)) + .count(), + 1 + ); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 1); + let admitted = is_relay_member(&pool, community, &first) + .await + .expect("first membership") as u8 + + is_relay_member(&pool, community, &second) + .await + .expect("second membership") as u8; + assert_eq!(admitted, 1); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expiry_and_tenant_scope_return_typed_failures() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2)) + .await + .expect("mint invite"); + let hash = hash_v2_code(&invite.code); + + assert_eq!( + claim_relay_invite(&pool, community_b, &hash, &test_pubkey(), None) + .await + .expect("cross-tenant claim"), + ClaimOutcome::Invalid + ); + + sqlx::query( + "UPDATE relay_invites SET expires_at = now() - interval '1 second' \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community_a.as_uuid()) + .bind(invite.invite_id) + .execute(&pool) + .await + .expect("expire invite"); + assert_eq!( + claim_relay_invite(&pool, community_a, &hash, &test_pubkey(), None) + .await + .expect("expired claim"), + ClaimOutcome::Expired + ); + assert_eq!(use_count(&pool, community_a, invite.invite_id).await, 0); + delete_test_community(&pool, community_a).await; + delete_test_community(&pool, community_b).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn retention_sweep_deletes_only_invites_older_than_cutoff() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint old invite"); + let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint recent invite"); + let cutoff = Utc::now() - chrono::Duration::days(30); + + sqlx::query("UPDATE relay_invites SET expires_at = $1 WHERE community_id = $2 AND id = $3") + .bind(cutoff - chrono::Duration::seconds(1)) + .bind(community.as_uuid()) + .bind(old.invite_id) + .execute(&pool) + .await + .expect("age old invite"); + + assert_eq!( + reap_expired_relay_invites(&pool, cutoff) + .await + .expect("reap expired invites"), + 1 + ); + let remaining: Vec = + sqlx::query_scalar("SELECT id FROM relay_invites WHERE community_id = $1 ORDER BY id") + .bind(community.as_uuid()) + .fetch_all(&pool) + .await + .expect("read remaining invites"); + assert_eq!(remaining, vec![recent.invite_id]); + + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unlimited_invites_count_each_new_member() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let invite = mint_relay_invite(&pool, community, "owner", 3600, None) + .await + .expect("mint unlimited invite"); + let hash = hash_v2_code(&invite.code); + + for (expected_count, pubkey) in [(1, test_pubkey()), (2, test_pubkey())] { + assert_eq!( + claim_relay_invite(&pool, community, &hash, &pubkey, None) + .await + .expect("unlimited claim"), + ClaimOutcome::Joined { + use_count: expected_count, + uses_remaining: None, + } + ); + } + assert_eq!(use_count(&pool, community, invite.invite_id).await, 2); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn policy_evidence_failure_rolls_back_membership_and_consumption() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let pubkey = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + let error = claim_relay_invite(&pool, community, &hash, &pubkey, Some("too-short")) + .await + .expect_err("policy CHECK must reject an invalid version"); + assert!(matches!(error, crate::DbError::Sqlx(_)), "{error:?}"); + assert!(!is_relay_member(&pool, community, &pubkey) + .await + .expect("membership after rollback")); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 0); + + assert!(matches!( + claim_relay_invite(&pool, community, &hash, &pubkey, None) + .await + .expect("claim after rollback"), + ClaimOutcome::Joined { use_count: 1, .. } + )); + delete_test_community(&pool, community).await; + } +} diff --git a/crates/buzz-db/src/workflow.rs b/crates/buzz-db/src/workflow.rs index 9c02f162c90..7a2396c1fd7 100644 --- a/crates/buzz-db/src/workflow.rs +++ b/crates/buzz-db/src/workflow.rs @@ -707,6 +707,36 @@ pub async fn set_workflow_enabled( Ok(()) } +/// Disable all of `owner_pubkey`'s workflows in a channel (SEC-006). +/// +/// Called when the owner loses channel membership (kind 9001 removal or kind +/// 9022 leave) so their workflows stop firing durably — across pods and +/// restarts — rather than only until the per-fire authority gate happens to +/// run. Idempotent; returns the number of workflows disabled so the caller +/// can decide whether a trigger-cache invalidation is needed. +pub async fn disable_workflows_for_owner_in_channel( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], +) -> Result { + let affected = sqlx::query( + r#" + UPDATE workflows + SET enabled = FALSE + WHERE community_id = $1 AND channel_id = $2 AND owner_pubkey = $3 AND enabled = TRUE + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(owner_pubkey) + .execute(pool) + .await? + .rows_affected(); + + Ok(affected) +} + /// Delete a workflow and all its runs/approvals (CASCADE). /// /// NOTE: see the cache-invalidation note on [`update_workflow`]. The relay's @@ -2272,4 +2302,100 @@ mod tests { "B's approval must remain pending after A is granted" ); } + + // -- SEC-006: disable-on-membership-loss primitive ------------------------- + + /// `disable_workflows_for_owner_in_channel` must disable exactly the + /// departing owner's enabled workflows in that channel — not other owners' + /// workflows, not the same owner's workflows in other channels — and be + /// idempotent. Disabled workflows must drop out of the trigger-path list. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn disable_for_owner_scopes_to_owner_and_channel() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + + let departing = vec![0xd1; 32]; + let staying = vec![0xd2; 32]; + ensure_user(&pool, community, &departing) + .await + .expect("ensure departing"); + ensure_user(&pool, community, &staying) + .await + .expect("ensure staying"); + + let channel_a = make_channel(&pool, community, &departing).await; + let channel_b = make_channel(&pool, community, &departing).await; + + let def = r#"{"trigger":{"on":"message_posted"},"steps":[]}"#; + let wf_departing_a = create_workflow( + &pool, + community, + Some(channel_a), + &departing, + "departing-a", + def, + &[0u8; 32], + ) + .await + .expect("wf departing a"); + let wf_departing_b = create_workflow( + &pool, + community, + Some(channel_b), + &departing, + "departing-b", + def, + &[0u8; 32], + ) + .await + .expect("wf departing b"); + let wf_staying_a = create_workflow( + &pool, + community, + Some(channel_a), + &staying, + "staying-a", + def, + &[0u8; 32], + ) + .await + .expect("wf staying a"); + + let disabled = + disable_workflows_for_owner_in_channel(&pool, community, channel_a, &departing) + .await + .expect("disable"); + assert_eq!( + disabled, 1, + "exactly the departing owner's channel-A workflow" + ); + + // Idempotent: second call finds nothing enabled. + let again = disable_workflows_for_owner_in_channel(&pool, community, channel_a, &departing) + .await + .expect("disable again"); + assert_eq!(again, 0, "second disable must be a no-op"); + + let enabled_a = list_enabled_channel_workflows(&pool, community, channel_a) + .await + .expect("list channel a"); + let enabled_a_ids: Vec = enabled_a.iter().map(|w| w.id).collect(); + assert!( + !enabled_a_ids.contains(&wf_departing_a), + "departing owner's workflow must no longer be trigger-eligible" + ); + assert!( + enabled_a_ids.contains(&wf_staying_a), + "other owners' workflows in the channel must be untouched" + ); + + let enabled_b = list_enabled_channel_workflows(&pool, community, channel_b) + .await + .expect("list channel b"); + assert!( + enabled_b.iter().any(|w| w.id == wf_departing_b), + "same owner's workflow in a different channel must be untouched" + ); + } } diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 10a83f03242..01f78a2d496 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -92,3 +92,4 @@ reqwest = { workspace = true } tokio-tungstenite = { workspace = true } futures = "0.3" flate2 = "1.1.9" +opentelemetry_sdk = { workspace = true, features = ["testing"] } diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index f0f4c5e633e..21f30065f0a 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -126,7 +126,7 @@ async fn report_detail( State(state): State>, headers: HeaderMap, Path(id): Path, -) -> Result, ApiError> { +) -> Result, ApiError> { authorize(&state, &headers)?; state .db @@ -374,6 +374,36 @@ mod tests { const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + #[tokio::test] + async fn report_detail_requires_admin_host_before_database_access() { + let response = router(test_state().await) + .oneshot( + Request::builder() + .uri(format!("/reports/{}", Uuid::nil())) + .header(header::HOST, "community.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn report_detail_rejects_unknown_report() { + let response = router(test_state().await) + .oneshot( + Request::builder() + .uri(format!("/reports/{}", Uuid::nil())) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); + } + #[tokio::test] async fn feedback_attachment_requires_admin_host_before_database_access() { let response = router(test_state().await) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 9cfa878d46f..b7d89f40a47 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2012,6 +2012,26 @@ pub async fn workflow_webhook( } let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok(); + // SEC-006: the webhook secret authenticates the *caller*, but the run + // executes with the workflow **owner's** standing authority — so the + // secret alone is insufficient. Immediately before run creation, reject + // disabled/inactive workflows and recheck the owner's current channel + // membership (and role, for exfiltration-capable definitions). Fail + // closed with the same generic 404 as the lookups above so a + // revoked-owner workflow is indistinguishable from a nonexistent one. + if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active { + return Err(not_found("workflow not found")); + } + let Some(wf_channel_id) = workflow.channel_id else { + // No channel scope means no channel authority to verify — fail closed. + return Err(not_found("workflow not found")); + }; + state + .workflow_engine + .check_owner_authority(community_id, wf_channel_id, &workflow.owner_pubkey, &def) + .await + .map_err(|_| not_found("workflow not found"))?; + let run_id = state .db .create_workflow_run(community_id, id, None, trigger_ctx_json.as_ref()) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 80e74e1ca0e..df5bdd4c3e1 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -63,8 +63,10 @@ const UPLOAD_PACK_MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024; /// Validates the `Authorization: Nostr ` header before the request body /// is read. Same pattern as `AuthenticatedUpload` in media.rs. /// -/// Authorization model: any authenticated pubkey can clone; push authorization -/// is handled by the pre-receive hook (calls back to the internal policy endpoint +/// Authorization model: reads (ref advertisement, upload-pack) require the +/// caller's *current* active membership in the repo's bound channel — see +/// [`authorize_git_read`] (SEC-005). Push authorization is additionally +/// handled by the pre-receive hook (calls back to the internal policy endpoint /// which checks channel role + protection rules from kind:30617). pub struct GitAuth { /// The authenticated user's public key, extracted from the NIP-98 event. @@ -354,6 +356,114 @@ fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Resp .into_response() } +/// SEC-005: authorize a repository *read* (ref advertisement, upload-pack). +/// +/// The authorization invariant is the authenticated git caller's **current +/// active membership in the repo's bound channel**. NIP-98 alone only proves +/// key possession — without this gate any authenticated pubkey (including a +/// member removed from the channel) can clone channel-bound repositories. +/// +/// Resolution follows the current authoritative announcement, the same +/// mapping the push policy endpoint uses: +/// 1. current live kind:30617 by `(community, owner pubkey from the URL, +/// d = canonical repo name)` — soft-deleted/replaced announcements do not +/// resolve; +/// 2. its `buzz-channel` tag → channel UUID; +/// 3. [`buzz_db::Db::get_member_role`] for the caller — a read is allowed +/// only on `Ok(Some(role))` with a role the relay recognizes. +/// +/// Fail-closed: missing/deleted announcement, invalid owner, missing or +/// malformed `buzz-channel` binding, non-member, unknown role, and every DB +/// error all deny. There is deliberately **no repo-owner bypass**: an owner +/// removed from the bound channel loses read access, which is the exact +/// exploit shape this gate closes. Every denial is the same generic 404 as a +/// nonexistent repo so membership cannot be probed through the git endpoints. +async fn authorize_git_read( + db: &buzz_db::Db, + community: buzz_core::CommunityId, + caller: &nostr::PublicKey, + owner_hex: &str, + repo_name: &str, +) -> Result<(), Response> { + fn denied() -> Response { + (StatusCode::NOT_FOUND, "repository not found").into_response() + } + + let Ok(owner_bytes) = hex::decode(owner_hex) else { + return Err(denied()); + }; + if owner_bytes.len() != 32 { + return Err(denied()); + } + + let query = buzz_db::EventQuery { + kinds: Some(vec![30617]), + pubkey: Some(owner_bytes), + d_tag: Some(repo_name.to_string()), + global_only: true, + limit: Some(1), + ..buzz_db::EventQuery::for_community(community) + }; + let repo_event = match db.query_events(&query).await { + Ok(mut events) => match events.pop() { + Some(event) => event, + None => return Err(denied()), + }, + Err(e) => { + error!(repo = %repo_name, error = %e, "git read gate: 30617 lookup failed (deny)"); + return Err(denied()); + } + }; + + let Some(channel_id) = repo_bound_channel_id(&repo_event.event) else { + warn!(repo = %repo_name, "git read gate: missing/malformed buzz-channel binding (deny)"); + return Err(denied()); + }; + + match db + .get_member_role(community, channel_id, &caller.to_bytes()) + .await + { + Ok(role) if read_role_allows(role.as_deref()) => Ok(()), + Ok(_) => Err(denied()), + Err(e) => { + error!(repo = %repo_name, error = %e, "git read gate: role lookup failed (deny)"); + Err(denied()) + } + } +} + +/// Extract the `buzz-channel` UUID from a kind:30617 announcement. +/// +/// First-tag semantics, matching the push policy endpoint: only the *first* +/// `buzz-channel` tag is considered, and it must carry a valid UUID. A +/// malformed first binding denies even if a later duplicate tag is valid — +/// an ambiguous announcement must fail closed, not silently resolve to +/// whichever duplicate happens to parse. +fn repo_bound_channel_id(event: &nostr::Event) -> Option { + let first = event + .tags + .iter() + .find(|t| t.as_slice().first().map(String::as_str) == Some("buzz-channel"))?; + first + .as_slice() + .get(1) + .and_then(|v| uuid::Uuid::parse_str(v).ok()) +} + +/// Pure decision for [`authorize_git_read`]: a read requires a current +/// active membership row whose role the relay recognizes. +/// +/// `None` = not an active member (removed, left, never joined) ⇒ deny. +/// An unrecognized role string ⇒ deny (fail-closed, same as the push +/// policy endpoint). +fn read_role_allows(role: Option<&str>) -> bool { + match role { + Some(r) => r.parse::().is_ok(), + None => false, + } +} + #[derive(Deserialize)] /// Query parameters for the `info/refs` endpoint. pub struct InfoRefsQuery { @@ -547,7 +657,19 @@ pub async fn info_refs( "git-upload-pack" | "git-receive-pack" => &query.service, _ => return Err((StatusCode::BAD_REQUEST, "invalid service").into_response()), }; - let _repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + + // SEC-005: channel-membership gate before any manifest load, hydration, + // or subprocess work. Both services — the receive-pack advertisement + // leaks the ref list just like the upload-pack one. + authorize_git_read( + &state.db, + auth.tenant.community(), + &auth.pubkey, + ¶ms.owner, + repo_name, + ) + .await?; // Track C fast path: only for clone advertisement. The receive-pack // advertisement carries a different capability set (report-status, @@ -790,7 +912,21 @@ pub async fn upload_pack( AxumPath(params): AxumPath, body: Body, ) -> Result { - let _ = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + + // SEC-005: the reused NIP-98 token means the GET advertisement's + // authorization cannot stand in for POST-time membership — gate this + // door independently, before body decode work is driven or hydration + // starts. + authorize_git_read( + &state.db, + auth.tenant.community(), + &auth.pubkey, + ¶ms.owner, + repo_name, + ) + .await?; + let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES); let permit = acquire_git_permit(&state, "upload_pack")?; @@ -2286,3 +2422,335 @@ mod track_c_tests { assert!(caps.contains("object-format=sha256")); } } + +#[cfg(test)] +mod sec005_read_gate_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + // ── Pure decision helpers ──────────────────────────────────────────── + + #[test] + fn read_role_allows_every_recognized_role() { + for role in ["owner", "admin", "member", "guest", "bot"] { + assert!(read_role_allows(Some(role)), "role {role:?} must allow"); + } + } + + #[test] + fn read_role_denies_non_members_and_unknown_roles() { + assert!(!read_role_allows(None), "no membership row must deny"); + assert!( + !read_role_allows(Some("superuser")), + "unrecognized role must deny (fail-closed)" + ); + assert!(!read_role_allows(Some("")), "empty role must deny"); + } + + fn announcement(keys: &Keys, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(30617), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign 30617") + } + + #[test] + fn repo_bound_channel_id_extracts_valid_uuid() { + let keys = Keys::generate(); + let ch = uuid::Uuid::new_v4(); + let event = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&event), Some(ch)); + } + + #[test] + fn repo_bound_channel_id_rejects_absent_and_malformed_bindings() { + let keys = Keys::generate(); + let absent = announcement(&keys, vec![Tag::parse(["d", "r"]).unwrap()]); + assert_eq!(repo_bound_channel_id(&absent), None); + + let malformed = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&malformed), None); + + let empty = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel"]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&empty), None); + } + + #[test] + fn repo_bound_channel_id_fails_closed_on_ambiguous_duplicate_bindings() { + // First-tag semantics: a malformed first binding must deny even when + // a later duplicate tag is valid. An ambiguous announcement must not + // silently resolve to whichever duplicate happens to parse. + let keys = Keys::generate(); + let ch = uuid::Uuid::new_v4(); + let malformed_first = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&malformed_first), None); + + // And the mirror image: a valid first binding wins, matching the + // push policy endpoint's first-tag resolution. + let other = uuid::Uuid::new_v4(); + let valid_first = announcement( + &keys, + vec![ + Tag::parse(["d", "r"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + Tag::parse(["buzz-channel", &other.to_string()]).unwrap(), + ], + ); + assert_eq!(repo_bound_channel_id(&valid_first), Some(ch)); + } + + // ── authorize_git_read matrix (requires Postgres) ──────────────────── + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_db() -> buzz_db::Db { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); + buzz_db::Db::from_pool(pool) + } + + /// How the fixture's kind:30617 binds (or fails to bind) a channel. + enum Binding { + /// `buzz-channel` tag carrying the fixture channel's UUID. + Channel, + /// No `buzz-channel` tag at all. + Missing, + /// `buzz-channel` tag whose value is not a UUID. + Malformed, + } + + struct RepoFixture { + db: buzz_db::Db, + community: buzz_core::CommunityId, + channel: uuid::Uuid, + owner_keys: Keys, + owner_hex: String, + member_keys: Keys, + repo: String, + } + + /// Community + channel + one plain member + a kind:30617 announcement. + /// The repo owner is a *different* key that is not a channel member — + /// deliberately, to pin "no repo-owner bypass". + async fn setup_repo(binding: Binding) -> RepoFixture { + let db = setup_db().await; + let host = format!("sec005-{}.example", uuid::Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + + let owner_keys = Keys::generate(); + let member_keys = Keys::generate(); + let creator = Keys::generate(); // channel creator, distinct from repo owner + let creator_pk = creator.public_key().to_bytes().to_vec(); + let member_pk = member_keys.public_key().to_bytes().to_vec(); + db.ensure_user(community, &creator_pk).await.expect("user"); + db.ensure_user(community, &member_pk).await.expect("user"); + + let channel = uuid::Uuid::new_v4(); + db.create_channel_with_id( + community, + channel, + &format!("ch-{}", channel.simple()), + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &creator_pk, + None, + ) + .await + .expect("channel"); + db.add_member( + community, + channel, + &member_pk, + buzz_core::channel::MemberRole::Member, + Some(&creator_pk), + ) + .await + .expect("member"); + + let repo = format!("repo-{}", uuid::Uuid::new_v4().simple()); + let mut tags = vec![Tag::parse(["d", &repo]).unwrap()]; + match binding { + Binding::Channel => { + tags.push(Tag::parse(["buzz-channel", &channel.to_string()]).unwrap()); + } + Binding::Missing => {} + Binding::Malformed => { + tags.push(Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap()); + } + } + let event = announcement(&owner_keys, tags); + db.insert_event(community, &event, None) + .await + .expect("30617"); + + let owner_hex = owner_keys.public_key().to_hex(); + RepoFixture { + db, + community, + channel, + owner_keys, + owner_hex, + member_keys, + repo, + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_gate_allows_current_member_denies_removed_and_owner_bypass() { + let f = setup_repo(Binding::Channel).await; + + // Current member: allowed. + let member = f.member_keys.public_key(); + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_ok(), + "current member must be allowed to read" + ); + + // Never-a-member caller: denied. + let stranger = Keys::generate().public_key(); + assert!( + authorize_git_read(&f.db, f.community, &stranger, &f.owner_hex, &f.repo) + .await + .is_err(), + "non-member must be denied" + ); + + // Member removed → denied. THE finding-005 exploit shape. + let member_pk = member.to_bytes().to_vec(); + f.db.remove_member(f.community, f.channel, &member_pk, &member_pk) + .await + .expect("self-remove"); + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_err(), + "removed member must be denied" + ); + + // No repo-owner bypass: the announcement author is not a channel + // member and must be denied too. + let owner = f.owner_keys.public_key(); + assert!( + authorize_git_read(&f.db, f.community, &owner, &f.owner_hex, &f.repo) + .await + .is_err(), + "repo owner outside the channel must be denied (no owner bypass)" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_gate_denies_missing_or_malformed_binding_and_absent_repo() { + // Missing buzz-channel tag → deny even for a channel member. + let f = setup_repo(Binding::Missing).await; + let member = f.member_keys.public_key(); + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_err(), + "announcement without buzz-channel binding must deny" + ); + + // Malformed buzz-channel tag → deny. + let g = setup_repo(Binding::Malformed).await; + let member_g = g.member_keys.public_key(); + assert!( + authorize_git_read(&g.db, g.community, &member_g, &g.owner_hex, &g.repo) + .await + .is_err(), + "announcement with malformed buzz-channel binding must deny" + ); + + // Nonexistent announcement → deny. + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, "no-such-repo") + .await + .is_err(), + "nonexistent repo must deny" + ); + + // Owner-mismatch: URL owner differs from announcement author → deny. + let impostor_hex = Keys::generate().public_key().to_hex(); + assert!( + authorize_git_read(&f.db, f.community, &member, &impostor_hex, &f.repo) + .await + .is_err(), + "URL owner that never announced this repo must deny" + ); + + // Invalid owner hex in URL → deny (never panics). + assert!( + authorize_git_read(&f.db, f.community, &member, "zz-not-hex", &f.repo) + .await + .is_err(), + "malformed owner hex must deny" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_gate_follows_current_announcement_not_stale_registry() { + // Max's registry/pointer concern: a soft-deleted 30617 can leave the + // `git_repo_names` reservation and the manifest pointer alive. Reads + // must follow the current authoritative announcement — once it's + // deleted, the gate denies even a current channel member. + let f = setup_repo(Binding::Channel).await; + + let member = f.member_keys.public_key(); + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_ok(), + "precondition: member allowed while announcement is live" + ); + + let owner_pk = f.owner_keys.public_key().to_bytes().to_vec(); + let deleted = + f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo) + .await + .expect("soft delete 30617"); + assert!(deleted, "precondition: a live announcement row was deleted"); + + assert!( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) + .await + .is_err(), + "deleted announcement must deny reads even for channel members" + ); + } +} diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6719e85f791..6104171ccad 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -25,7 +25,12 @@ use serde::Deserialize; use serde_json::Value; use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; -use crate::invite_token::{self, DEFAULT_INVITE_TTL_SECS}; +use buzz_core::invite::{ + hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, + MIN_INVITE_TTL_SECS, V2_PREFIX, +}; + +use crate::invite_token; use crate::state::AppState; use super::{api_error, bridge, internal_error}; @@ -43,10 +48,42 @@ pub(crate) const CLAIM_RATE_CACHE_CAPACITY: u64 = 10_000; /// Body for `POST /api/invites`. #[derive(Debug, Default, Deserialize)] pub struct MintInviteRequest { - /// Requested lifetime in seconds. Clamped to + /// Requested lifetime in seconds. Must be between + /// [`MIN_INVITE_TTL_SECS`] and /// [`invite_token::MAX_INVITE_TTL_SECS`]; defaults to 72 h. #[serde(default)] pub ttl_secs: Option, + /// Maximum number of uses before the invite is exhausted. `None` (omitted + /// or `null`) means unlimited — preserves current behavior. When present, + /// must be an integer from 1 through [`MAX_INVITE_USES`]. + #[serde(default)] + pub max_uses: Option, +} + +fn validate_mint_request( + request: &MintInviteRequest, +) -> Result<(u64, Option), (StatusCode, Json)> { + let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS); + if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl) { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "ttl_secs must be between {} and {MAX_INVITE_TTL_SECS}", + MIN_INVITE_TTL_SECS + ), + )); + } + + if let Some(max_uses) = request.max_uses { + if !(1..=MAX_INVITE_USES).contains(&max_uses) { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!("max_uses must be between 1 and {MAX_INVITE_USES}"), + )); + } + } + + Ok((ttl, request.max_uses)) } /// Body for `POST /api/invites/claim`. @@ -260,9 +297,17 @@ pub async fn mint_invite( })? }; - let key = invite_token::derive_invite_key(&state.relay_keypair); - let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS); - let (code, expires_at) = invite_token::mint_invite(&key, tenant.community(), ttl); + let (ttl, max_uses) = validate_mint_request(&request)?; + + // Mint a v2 opaque, database-backed invite. + let invite = state + .db + .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) + .await + .map_err(|error| match error { + buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), + error => internal_error(&format!("invite mint: {error}")), + })?; // Same TLS-posture logic as nip98_expected_url: wss deployments get an // https landing page URL, ws dev/test deployments get http. @@ -275,19 +320,30 @@ pub async fn mint_invite( tracing::info!( community = %tenant.community(), minted_by = %sender_hex, - expires_at, + invite_id = %invite.invite_id, + expires_at = %invite.expires_at, + max_uses = ?invite.max_uses, "relay invite minted" ); + // expires_at as unix seconds for the response contract. + let expires_at_unix = invite.expires_at.timestamp() as u64; + Ok(Json(serde_json::json!({ - "code": code, - "expires_at": expires_at, - "url": format!("{scheme}://{}/invite/{}", tenant.host(), code), + "code": invite.code, + "expires_at": expires_at_unix, + "max_uses": invite.max_uses, + "uses_remaining": invite.uses_remaining, + "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), }))) } /// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the /// *joining* pubkey. Exempt from the relay-membership gate by design. +/// +/// Routing is by exact prefix: `v2.` codes go to the database-backed +/// redemption path; every other code goes to the v1 HMAC verifier. A `v2.` +/// code is never fallen back to v1 verification. pub async fn claim_invite( State(state): State>, headers: HeaderMap, @@ -305,7 +361,88 @@ pub async fn claim_invite( let request: ClaimInviteRequest = serde_json::from_slice(&body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?; + let claimer_hex = pubkey.to_hex(); let key = invite_token::derive_invite_key(&state.relay_keypair); + + // --- v2 database-backed path --- + // + // Route by exact prefix: v2. codes use the durable invite table. No + // fallback to v1 HMAC verification for malformed v2 input. + if request.code.starts_with(V2_PREFIX) { + validate_v2_code(&request.code) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_invalid"))?; + + // Join-policy receipt verification, same mechanism as v1: the receipt + // is bound to the code string by SHA-256, so it works for v2 codes. + if let Some(policy) = &state.config.join_policy { + let receipt = request + .policy_receipt + .as_deref() + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; + invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; + } + + let token_hash = hash_v2_code(&request.code); + let outcome = state + .db + .claim_relay_invite( + tenant.community(), + &token_hash, + &claimer_hex, + state + .config + .join_policy + .as_ref() + .map(|policy| policy.version.as_str()), + ) + .await + .map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?; + + return match outcome { + buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { + tracing::info!( + community = %tenant.community(), + member = %claimer_hex, + "relay member added via v2 invite" + ); + // NIP-43 side effects only on Joined, never on other outcomes. + if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { + tracing::warn!( + "failed to publish NIP-43 member-added delta after v2 claim: {e}" + ); + } + if let Err(e) = publish_nip43_membership_list(&tenant, &state).await { + tracing::warn!("failed to publish NIP-43 membership list after v2 claim: {e}"); + } + Ok(Json(serde_json::json!({ + "status": "joined", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }))) + } + buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => { + Ok(Json(serde_json::json!({ + "status": "already_member", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }))) + } + buzz_db::relay_invite::ClaimOutcome::Expired => { + Err(api_error(StatusCode::FORBIDDEN, "invite_expired")) + } + buzz_db::relay_invite::ClaimOutcome::Exhausted => { + Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted")) + } + buzz_db::relay_invite::ClaimOutcome::Invalid => { + Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) + } + }; + } + + // --- v1 HMAC path (stateless tokens, drain window) --- let payload = invite_token::verify_invite(&key, tenant.community(), &request.code).map_err( |e| match e { // Expired is post-MAC: revealing it helps the UX without helping a forger. @@ -317,7 +454,6 @@ pub async fn claim_invite( }, )?; - let claimer_hex = pubkey.to_hex(); if let Some(policy) = &state.config.join_policy { let receipt = request .policy_receipt @@ -395,7 +531,7 @@ mod tests { use std::sync::Arc; use std::time::Duration; - use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT}; + use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT, MAX_INVITE_USES, MIN_INVITE_TTL_SECS}; use axum::{ body::{to_bytes, Body}, http::{header, Request, StatusCode}, @@ -409,7 +545,7 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; - use crate::invite_token::{derive_invite_key, InvitePayload}; + use crate::invite_token::{derive_invite_key, InvitePayload, MAX_INVITE_TTL_SECS}; use crate::router::build_router; use crate::state::AppState; @@ -592,6 +728,355 @@ mod tests { serde_json::from_slice(&bytes).expect("response JSON") } + async fn mint_code(state: Arc, host: &str, owner: &Keys, request: Value) -> String { + let response = post_json(state, host, "/api/invites", owner, request.to_string()).await; + assert_eq!(response.status(), StatusCode::OK); + read_json(response) + .await + .get("code") + .and_then(Value::as_str) + .expect("minted code") + .to_string() + } + + async fn event_count(state: &AppState, community: buzz_core::CommunityId, kind: i32) -> i64 { + state + .db + .count_events(&buzz_db::EventQuery { + kinds: Some(vec![kind]), + global_only: true, + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("count side-effect events") + } + + #[test] + fn mint_request_deserialization_is_strict() { + for valid in [ + serde_json::json!({}), + serde_json::json!({ "max_uses": null }), + serde_json::json!({ "max_uses": 1 }), + serde_json::json!({ "max_uses": MAX_INVITE_USES }), + ] { + serde_json::from_value::(valid).expect("valid request"); + } + + for invalid in [ + serde_json::json!({ "max_uses": 1.5 }), + serde_json::json!({ "max_uses": "10" }), + serde_json::json!({ "ttl_secs": -1 }), + serde_json::json!({ "ttl_secs": 1.5 }), + serde_json::json!({ "ttl_secs": "3600" }), + ] { + assert!( + serde_json::from_value::(invalid.clone()).is_err(), + "accepted wrong JSON type: {invalid}" + ); + } + } + + #[test] + fn mint_request_validation_enforces_bounds_without_a_database() { + use super::validate_mint_request; + + for (request, expected) in [ + ( + super::MintInviteRequest::default(), + (crate::invite_token::DEFAULT_INVITE_TTL_SECS, None), + ), + ( + super::MintInviteRequest { + ttl_secs: Some(MIN_INVITE_TTL_SECS), + max_uses: Some(1), + }, + (MIN_INVITE_TTL_SECS, Some(1)), + ), + ( + super::MintInviteRequest { + ttl_secs: Some(MAX_INVITE_TTL_SECS), + max_uses: Some(MAX_INVITE_USES), + }, + (MAX_INVITE_TTL_SECS, Some(MAX_INVITE_USES)), + ), + ] { + assert_eq!( + validate_mint_request(&request).expect("valid request"), + expected + ); + } + + for request in [ + super::MintInviteRequest { + ttl_secs: None, + max_uses: Some(0), + }, + super::MintInviteRequest { + ttl_secs: None, + max_uses: Some(-1), + }, + super::MintInviteRequest { + ttl_secs: None, + max_uses: Some(MAX_INVITE_USES + 1), + }, + super::MintInviteRequest { + ttl_secs: Some(MIN_INVITE_TTL_SECS - 1), + max_uses: None, + }, + super::MintInviteRequest { + ttl_secs: Some(MAX_INVITE_TTL_SECS + 1), + max_uses: None, + }, + ] { + assert_eq!( + validate_mint_request(&request) + .expect_err("invalid request") + .0, + StatusCode::BAD_REQUEST + ); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn mint_validates_max_uses_and_ttl_bounds() { + let host = format!("invites-validation-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + state + .db + .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + + for body in [ + serde_json::json!({ "max_uses": 0 }), + serde_json::json!({ "max_uses": -1 }), + serde_json::json!({ "max_uses": MAX_INVITE_USES + 1 }), + serde_json::json!({ "ttl_secs": MIN_INVITE_TTL_SECS - 1 }), + serde_json::json!({ "ttl_secs": MAX_INVITE_TTL_SECS + 1 }), + ] { + let response = post_json( + state.clone(), + &host, + "/api/invites", + &owner, + body.to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{body}"); + } + + for body in [ + serde_json::json!({}), + serde_json::json!({ "max_uses": null }), + serde_json::json!({ "max_uses": 1 }), + serde_json::json!({ "max_uses": MAX_INVITE_USES }), + serde_json::json!({ "ttl_secs": MIN_INVITE_TTL_SECS }), + serde_json::json!({ "ttl_secs": MAX_INVITE_TTL_SECS }), + ] { + let response = post_json( + state.clone(), + &host, + "/api/invites", + &owner, + body.to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "{body}"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn malformed_and_unknown_v2_codes_are_forbidden_without_v1_fallback() { + let host = format!("invites-v2-invalid-{}.example", Uuid::new_v4().simple()); + let joiner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let unknown = format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 32])); + + for code in [ + "v2.".to_string(), + "v2.not-base64!".to_string(), + format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 31])), + unknown, + ] { + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": code }).to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{code}"); + assert_eq!( + read_json(response) + .await + .get("error") + .and_then(Value::as_str), + Some("invite_invalid") + ); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bounded_v2_claims_publish_side_effects_only_for_joined() { + let host = format!( + "invites-v2-side-effects-{}.example", + Uuid::new_v4().simple() + ); + let owner = Keys::generate(); + let first = Keys::generate(); + let second = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + state + .db + .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + let before_delta_count = event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32, + ) + .await; + let before_list_count = event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32, + ) + .await; + let code = mint_code( + state.clone(), + &host, + &owner, + serde_json::json!({ "max_uses": 1 }), + ) + .await; + let claim_body = serde_json::json!({ "code": code }).to_string(); + + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &first, + claim_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + read_json(response) + .await + .get("status") + .and_then(Value::as_str), + Some("joined") + ); + let delta_count = event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32, + ) + .await; + let list_count = event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32, + ) + .await; + assert_eq!(delta_count, before_delta_count + 1); + assert_eq!(list_count, before_list_count + 1); + + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &first, + claim_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + read_json(response) + .await + .get("status") + .and_then(Value::as_str), + Some("already_member") + ); + assert_eq!( + event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32, + ) + .await, + delta_count + ); + assert_eq!( + event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32, + ) + .await, + list_count + ); + + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &second, + claim_body, + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + read_json(response) + .await + .get("error") + .and_then(Value::as_str), + Some("invite_exhausted") + ); + assert_eq!( + event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32, + ) + .await, + delta_count + ); + assert_eq!( + event_count( + &state, + community.id, + buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32, + ) + .await, + list_count + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn owner_mints_and_new_pubkey_claims() { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 80188c1e142..057c260b082 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -64,6 +64,14 @@ pub struct Config { /// pod is only 4 — small enough that rate-limit checks, presence, and /// pub/sub publishes queue behind each other under load. pub redis_pool_size: usize, + /// Maximum connections in the Postgres writer/reader pools. Defaults to 50. + /// + /// The `buzz-db` default of 20 was sized for a handful of pods against + /// `max_connections=100`. Against Aurora (~5,000 connections) that cap + /// is the binding constraint: a burst of concurrent handlers exhausts + /// the per-pod pool and requests fail on acquire timeout while the + /// database sits idle. + pub db_pool_size: u32, /// Public WebSocket URL of this relay, advertised in NIP-11. pub relay_url: String, /// Public WebSocket URL of the dedicated device-pairing relay, when configured. @@ -431,6 +439,12 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(16); + let db_pool_size = std::env::var("BUZZ_DB_POOL_SIZE") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(50); + let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); @@ -908,6 +922,7 @@ impl Config { read_database_url, redis_url, redis_pool_size, + db_pool_size, relay_url, pairing_relay_url, max_connections, @@ -976,6 +991,7 @@ mod tests { assert!(!config.database_url.is_empty()); assert!(!config.redis_url.is_empty()); assert_eq!(config.redis_pool_size, 16); + assert_eq!(config.db_pool_size, 50); assert!(config.max_connections > 0); assert!(config.send_buffer_size > 0); assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES); @@ -1043,6 +1059,31 @@ mod tests { assert_eq!(junk, 16, "unparsable value must fall back to the default"); } + #[test] + fn db_pool_size_env_override_and_invalid_fallback() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_DB_POOL_SIZE"); + + std::env::set_var("BUZZ_DB_POOL_SIZE", "80"); + let overridden = Config::from_env().expect("config").db_pool_size; + + std::env::set_var("BUZZ_DB_POOL_SIZE", "0"); + let zero = Config::from_env().expect("config").db_pool_size; + + std::env::set_var("BUZZ_DB_POOL_SIZE", "not-a-number"); + let junk = Config::from_env().expect("config").db_pool_size; + + if let Some(value) = previous { + std::env::set_var("BUZZ_DB_POOL_SIZE", value); + } else { + std::env::remove_var("BUZZ_DB_POOL_SIZE"); + } + + assert_eq!(overridden, 80); + assert_eq!(zero, 50, "zero must fall back to the default"); + assert_eq!(junk, 50, "unparsable value must fall back to the default"); + } + #[test] fn read_database_url_unset_or_blank_is_none() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index bd3eff4de9b..2d82736807a 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -685,6 +685,24 @@ async fn handle_workflow_def( .map_err(|e| IngestError::Rejected(format!("invalid: workflow YAML parse error: {e}")))?; let workflow_name = extract_tag(event, "name").unwrap_or_else(|| def.name.clone()); + // SEC-006: definitions with exfiltration-capable actions (call_webhook) + // require elevated channel authority to save — plain membership is not + // enough, because the workflow will forward channel content outward with + // the owner's standing authority. Fail-closed on lookup errors. + if def.requires_elevated_authority() { + let role = state + .db + .get_member_role(tenant.community(), channel_id, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: role check: {e}")))?; + if !matches!(role.as_deref(), Some("owner") | Some("admin")) { + return Err(IngestError::Rejected( + "forbidden: workflows with call_webhook actions require the owner or admin role" + .into(), + )); + } + } + let mut definition_json: serde_json::Value = serde_json::from_str(&definition_json_str) .map_err(|e| IngestError::Internal(format!("error: json parse of definition: {e}")))?; @@ -843,6 +861,31 @@ async fn handle_workflow_trigger( )); } + // SEC-006: manual triggers must honor the workflow's lifecycle state and + // recheck the owner's *current* channel authority before creating a run. + // Without this, a disabled workflow — including one disabled because its + // owner was removed from the channel — could still be fired by the owner. + if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active { + return Err(IngestError::Rejected( + "forbidden: workflow is disabled or inactive".into(), + )); + } + let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) + .map_err(|e| IngestError::Internal(format!("error: corrupt workflow definition: {e}")))?; + let Some(wf_channel_id) = workflow.channel_id else { + // No channel scope means no channel authority to verify — fail closed. + return Err(IngestError::Rejected( + "forbidden: workflow has no channel scope".into(), + )); + }; + state + .workflow_engine + .check_owner_authority(community_id, wf_channel_id, &workflow.owner_pubkey, &def) + .await + .map_err(|_| { + IngestError::Rejected("forbidden: not authorized to trigger this workflow".into()) + })?; + // Persist the command event under the workflow channel even though the // trigger event itself only carries the workflow UUID. Storing channel // triggers as global events leaks workflow IDs to unrelated relay members. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 6ff1315acc7..65d04ef0bac 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -51,6 +51,52 @@ async fn evict_live_channel_subscriptions( } } +/// Durably disable a departing member's workflows in the channel (SEC-006). +/// +/// A workflow runs with its owner's standing authority; once the owner is no +/// longer a member (removed via kind 9001 or left via kind 9022) their +/// workflows must stop firing on every path — event triggers, the scheduler, +/// manual triggers, and the webhook endpoint all honor `enabled = FALSE`. +/// The per-fire authority gate in `buzz-workflow` is the fail-closed backstop; +/// this makes the revocation durable and immediately visible. +/// +/// Failures are logged, not propagated: membership removal has already been +/// committed, and the per-fire gate still denies a removed owner even if this +/// disable write is lost. +async fn disable_departed_member_workflows( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + target_pubkey: &[u8], +) { + match state + .db + .disable_workflows_for_owner_in_channel(tenant.community(), channel_id, target_pubkey) + .await + { + Ok(0) => {} + Ok(n) => { + tracing::info!( + channel = %channel_id, + owner = %hex::encode(target_pubkey), + disabled = n, + "Disabled departed member's workflows" + ); + state + .workflow_engine + .invalidate_channel_workflows(tenant.community(), channel_id); + } + Err(e) => { + warn!( + channel = %channel_id, + owner = %hex::encode(target_pubkey), + error = %e, + "Failed to disable departed member's workflows — per-fire authority gate still denies" + ); + } + } +} + /// Close every live channel-scoped subscription on `conn_id`, removing them from /// the connection's local map and sending `CLOSED restricted` for each. async fn evict_conn_channel_subscriptions( @@ -1341,6 +1387,7 @@ async fn handle_remove_user( .await?; state.invalidate_membership(tenant, channel_id, &target_pubkey); evict_live_channel_subscriptions(tenant, state, channel_id, &target_pubkey).await; + disable_departed_member_workflows(tenant, state, channel_id, &target_pubkey).await; let actor_hex = hex::encode(&actor_bytes); let target_hex = hex::encode(&target_pubkey); @@ -1987,6 +2034,7 @@ async fn handle_leave_request( .await?; state.invalidate_membership(tenant, channel_id, &actor_bytes); evict_live_channel_subscriptions(tenant, state, channel_id, &actor_bytes).await; + disable_departed_member_workflows(tenant, state, channel_id, &actor_bytes).await; let actor_hex = hex::encode(&actor_bytes); emit_system_message( diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs index 436040c32a6..e271f40f0b5 100644 --- a/crates/buzz-relay/src/invite_token.rs +++ b/crates/buzz-relay/src/invite_token.rs @@ -49,10 +49,10 @@ use buzz_core::tenant::CommunityId; type HmacSha256 = Hmac; /// Default invite lifetime: 72 hours. -pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60; +pub use buzz_core::invite::DEFAULT_INVITE_TTL_SECS; /// Maximum invite lifetime a mint request may ask for: 30 days. -pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60; +pub use buzz_core::invite::MAX_INVITE_TTL_SECS; /// Maximum accepted code length (defense against absurd inputs before any /// parsing work happens). A real code is ~200 bytes. @@ -121,10 +121,11 @@ fn sign_payload(key: &[u8; 32], payload_bytes: &[u8]) -> Vec { mac.finalize().into_bytes().to_vec() } -/// Mint an invite code for `community`, expiring `ttl_secs` from now. +/// Mint a legacy v1 invite code for compatibility tests. /// -/// The role is fixed to `"member"` — elevated roles are granted post-join via -/// the existing kind:9032 change-role command, never via a bearer link. +/// Production minting uses database-backed v2 codes. Remove this helper with +/// v1 claim verification after the compatibility drain window. +#[cfg(test)] pub fn mint_invite(key: &[u8; 32], community: CommunityId, ttl_secs: u64) -> (String, u64) { let ttl = ttl_secs.clamp(60, MAX_INVITE_TTL_SECS); let expires_at = now_unix() + ttl; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 22101219ebf..3ed820d3c50 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -4,6 +4,10 @@ use std::sync::Arc; use tracing::{error, info, warn}; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +fn log_env_filter(rust_log: Option<&str>) -> EnvFilter { + EnvFilter::new(rust_log.unwrap_or("buzz_relay=info")) +} use uuid::Uuid; use buzz_audit::AuditService; @@ -107,9 +111,17 @@ async fn main() -> anyhow::Result<()> { }; tracing_subscriber::registry() - .with(fmt::layer().json().flatten_event(true)) - .with(EnvFilter::from_default_env().add_directive("buzz_relay=info".parse()?)) - .with(otel_layer) + .with( + fmt::layer() + .json() + .flatten_event(true) + .with_filter(log_env_filter(std::env::var("RUST_LOG").ok().as_deref())), + ) + .with(otel_layer.map(|layer| { + layer.with_filter(telemetry::otel_env_filter( + std::env::var("BUZZ_OTEL_FILTER").ok().as_deref(), + )) + })) .init(); // Log any exporter-build failure now that the subscriber is installed. @@ -146,6 +158,7 @@ async fn main() -> anyhow::Result<()> { let db_config = DbConfig { database_url: config.database_url.clone(), read_database_url: config.read_database_url.clone(), + max_connections: config.db_pool_size, ..DbConfig::default() }; let db = Db::new(&db_config).await.map_err(|e| { @@ -1060,6 +1073,52 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +#[cfg(test)] +mod env_filter_tests { + use super::log_env_filter; + use buzz_relay::telemetry::otel_env_filter; + use tracing_subscriber::prelude::*; + + #[test] + fn unset_enables_datastore_only_for_otel_filter() { + let logs = tracing_subscriber::registry().with(log_env_filter(None)); + tracing::subscriber::with_default(logs, || { + assert!(!tracing::enabled!(target: "buzz_datastore", tracing::Level::INFO)); + assert!(tracing::enabled!(target: "buzz_relay", tracing::Level::INFO)); + }); + + let otel = tracing_subscriber::registry().with(otel_env_filter(None)); + tracing::subscriber::with_default(otel, || { + assert!(tracing::enabled!(target: "buzz_datastore", tracing::Level::INFO)); + }); + } + + #[test] + fn explicit_datastore_off_is_preserved_alone() { + assert_eq!( + otel_env_filter(Some("buzz_datastore=off")).to_string(), + "buzz_datastore=off" + ); + } + + #[test] + fn explicit_datastore_debug_is_preserved_alone() { + assert_eq!( + otel_env_filter(Some("buzz_datastore=debug")).to_string(), + "buzz_datastore=debug" + ); + } + + #[test] + fn log_and_otel_filters_are_configured_independently() { + assert_eq!(log_env_filter(Some("warn")).to_string(), "warn"); + assert_eq!( + otel_env_filter(Some("buzz_relay=debug")).to_string(), + "buzz_relay=debug" + ); + } +} + async fn run_community_revalidator( state: Arc, period: std::time::Duration, @@ -1426,6 +1485,20 @@ async fn run_usage_metrics_tick( *leader = None; return Err(error); } + let invite_retention_cutoff = chrono::Utc::now() - chrono::Duration::days(30); + match state + .db + .reap_expired_relay_invites(invite_retention_cutoff) + .await + { + Ok(deleted) if deleted > 0 => { + info!(deleted, "reaped expired relay invites"); + } + Ok(_) => {} + Err(error) => { + warn!(error = %error, "failed to reap expired relay invites"); + } + } run_storage_sweep_tick(state, emission_scope, &host_map).await; } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 2af036079ff..400ed1dfe34 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -4,8 +4,9 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use axum::{ + body::Body, extract::{ConnectInfo, FromRequest, State, WebSocketUpgrade}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, Request, StatusCode}, middleware, response::{IntoResponse, Json}, routing::{get, post, put}, @@ -16,7 +17,7 @@ use tower::ServiceExt; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; -use tower_http::trace::TraceLayer; +use tower_http::trace::{HttpMakeClassifier, TraceLayer}; use crate::api; use crate::audio; @@ -187,10 +188,23 @@ pub fn build_router(state: Arc) -> Router { merged .layer(middleware::from_fn(track_metrics)) - .layer(TraceLayer::new_for_http()) + .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) } +fn http_trace_layer() -> TraceLayer) -> tracing::Span> { + TraceLayer::new_for_http().make_span_with(make_http_span as fn(&Request) -> tracing::Span) +} + +fn make_http_span(request: &Request) -> tracing::Span { + tracing::info_span!( + target: "buzz_relay", + "http.request", + otel.kind = "server", + http.request.method = %request.method(), + ) +} + fn is_admin_spa_path(path: &str) -> bool { path == "/" || path == "/reports" @@ -435,9 +449,14 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { mod tests { use axum::{routing::get, Router}; use futures_util::SinkExt; + use opentelemetry::trace::TracerProvider as _; + use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; + use tower::ServiceBuilder; + use tracing::Instrument as _; + use tracing_subscriber::prelude::*; use super::*; @@ -471,6 +490,60 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + #[tokio::test(flavor = "current_thread")] + async fn http_and_datastore_spans_are_exported_in_the_same_trace() { + let exporter = InMemorySpanExporter::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with( + tracing_opentelemetry::layer() + .with_tracer(provider.tracer("test")) + .with_filter(crate::telemetry::otel_env_filter(None)), + ); + let _subscriber_guard = tracing::subscriber::set_default(subscriber); + let service = ServiceBuilder::new() + .layer(http_trace_layer()) + .service(tower::service_fn( + |_: axum::http::Request| async { + async {} + .instrument(tracing::info_span!( + target: "buzz_datastore", + "SELECT", + otel.kind = "client", + db.system.name = "postgresql", + )) + .await; + Ok::<_, std::convert::Infallible>(axum::response::Response::new( + axum::body::Body::empty(), + )) + }, + )); + + service + .oneshot( + axum::http::Request::get("/") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + let http = spans + .iter() + .find(|span| span.name == "http.request") + .unwrap(); + let datastore = spans.iter().find(|span| span.name == "SELECT").unwrap(); + + assert_eq!( + datastore.span_context.trace_id(), + http.span_context.trace_id() + ); + assert_eq!(datastore.parent_span_id, http.span_context.span_id()); + } + async fn handler_receives_message_with_limit(limit: usize, size: usize) -> bool { let (received_tx, mut received_rx) = mpsc::unbounded_channel(); let app = Router::new().route( diff --git a/crates/buzz-relay/src/telemetry.rs b/crates/buzz-relay/src/telemetry.rs index 8dd305cd422..11c6d035128 100644 --- a/crates/buzz-relay/src/telemetry.rs +++ b/crates/buzz-relay/src/telemetry.rs @@ -25,6 +25,16 @@ use opentelemetry_otlp::ExporterBuildError; use opentelemetry_sdk::{resource::EnvResourceDetector, trace::SdkTracerProvider, Resource}; +use tracing_subscriber::EnvFilter; + +/// Build the filter for spans exported through OpenTelemetry. +/// +/// This is intentionally independent from `RUST_LOG`: changing stdout log +/// verbosity must not remove parent spans from exported traces. Set +/// `BUZZ_OTEL_FILTER` to override the default targets. +pub fn otel_env_filter(configured: Option<&str>) -> EnvFilter { + EnvFilter::new(configured.unwrap_or("buzz_relay=info,buzz_datastore=info")) +} /// Build the OTEL [`Resource`] used by the trace provider. /// diff --git a/crates/buzz-workflow/src/error.rs b/crates/buzz-workflow/src/error.rs index 34e3d5311fc..292f8dd027c 100644 --- a/crates/buzz-workflow/src/error.rs +++ b/crates/buzz-workflow/src/error.rs @@ -54,6 +54,12 @@ pub enum WorkflowError { #[error("database error: {0}")] Database(String), + /// The workflow's owner is not currently authorized to run it (removed + /// from the channel, insufficient role for the definition's actions, or + /// the authority lookup failed — all deny, fail-closed). + #[error("unauthorized: {0}")] + Unauthorized(String), + /// The action is defined but not yet implemented. #[error("action not implemented: {0}")] NotImplemented(String), diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 93581225ee8..7aaa3d17026 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -132,6 +132,44 @@ impl WorkflowEngine { self.workflow_cache.invalidate(&(community_id, channel_id)); } + /// Fail-closed pre-run authority gate (SEC-006). + /// + /// A workflow executes with its **owner's** standing authority long after + /// the definition was saved, so every run-creation door must recheck the + /// owner's *current* channel authority immediately before creating a run: + /// + /// - the owner must still be an active member of the workflow's channel; + /// - if the definition contains an exfiltration-capable action + /// (`call_webhook`), the owner must currently hold the `owner` or + /// `admin` role. + /// + /// Any lookup error denies (fail-closed): a removed owner must never keep + /// exfiltration authority because a membership read happened to fail. + pub async fn check_owner_authority( + &self, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + def: &WorkflowDef, + ) -> Result<(), WorkflowError> { + let role = self + .db + .get_member_role(community_id, channel_id, owner_pubkey) + .await + .map_err(|e| { + WorkflowError::Unauthorized(format!( + "owner authority lookup failed (fail-closed): {e}" + )) + })?; + if owner_authority_allows(role.as_deref(), def.requires_elevated_authority()) { + Ok(()) + } else { + Err(WorkflowError::Unauthorized( + "workflow owner lacks current channel authority".into(), + )) + } + } + /// Set the action sink. Called once after `AppState` construction. /// /// # Panics @@ -340,6 +378,23 @@ impl WorkflowEngine { continue; } + // SEC-006: recheck the owner's *current* channel authority + // immediately before run creation. The cached workflow list can be + // up to 10s stale, and disable-on-removal can race a concurrent + // event — this per-fire gate is the authoritative, fail-closed + // check that a removed (or under-privileged, for exfiltration + // definitions) owner cannot cause a run. + if let Err(e) = self + .check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def) + .await + { + tracing::warn!( + workflow_id = %workflow.id, + "Skipping workflow — owner authority check failed: {e}" + ); + continue; + } + let trigger_event_id_bytes = event.event.id.as_bytes().to_vec(); let run_id = match self .db @@ -537,6 +592,22 @@ impl WorkflowEngine { _ => continue, // Non-schedule triggers handled by on_event() }; + // SEC-006: recheck the owner's current channel authority + // BEFORE the durable claim. Placing the gate after the claim + // would let a revoked owner's workflow consume the + // at-most-once fire slot (claims are never re-fired), turning + // revocation into a denial-of-fire for a later re-enable. + if let Err(e) = self + .check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def) + .await + { + tracing::warn!( + workflow_id = %workflow.id, + "Cron tick: skipping workflow — owner authority check failed: {e}" + ); + continue; + } + // Durable at-most-once claim — the cross-pod fire boundary. // The loser receives `None` and skips BEFORE any run creation or // side effect. `community_id` is the workflow row's own @@ -951,6 +1022,25 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge } } +/// Pure authority decision for [`WorkflowEngine::check_owner_authority`]. +/// +/// `role` is the owner's *current* active role in the workflow's channel +/// (`None` = not an active member — removed, left, or never joined). +/// `needs_elevated` is true when the definition contains an +/// exfiltration-capable action (see `WorkflowDef::requires_elevated_authority`). +/// +/// Rules: +/// - not a member ⇒ deny, always; +/// - member ⇒ allowed for ordinary definitions; +/// - elevated definitions ⇒ only `owner` / `admin` roles. +fn owner_authority_allows(role: Option<&str>, needs_elevated: bool) -> bool { + match role { + None => false, + Some(r) if needs_elevated => matches!(r, "owner" | "admin"), + Some(_) => true, + } +} + /// Returns `true` if the trigger type matches the given event kind. fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool { use buzz_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF}; @@ -1561,4 +1651,244 @@ steps: // Should pick the LAST e tag (direct target), not the first (thread root) assert_eq!(ctx.message_id, direct_target_id.to_hex()); } + + // -- SEC-006: owner authority decision -------------------------------- + + #[test] + fn owner_authority_denies_non_members_always() { + assert!(!owner_authority_allows(None, false)); + assert!(!owner_authority_allows(None, true)); + } + + #[test] + fn owner_authority_allows_any_member_for_ordinary_definitions() { + assert!(owner_authority_allows(Some("member"), false)); + assert!(owner_authority_allows(Some("admin"), false)); + assert!(owner_authority_allows(Some("owner"), false)); + } + + #[test] + fn owner_authority_requires_elevated_role_for_exfiltration_definitions() { + assert!(!owner_authority_allows(Some("member"), true)); + assert!(owner_authority_allows(Some("admin"), true)); + assert!(owner_authority_allows(Some("owner"), true)); + } + + #[test] + fn requires_elevated_authority_detects_call_webhook() { + let (plain, _) = WorkflowEngine::parse_yaml(concat!( + "name: plain\n", + "trigger:\n on: message_posted\n", + "steps:\n - id: s1\n action: send_message\n text: hi\n", + )) + .expect("parse plain"); + assert!(!plain.requires_elevated_authority()); + + let (hook, _) = WorkflowEngine::parse_yaml(concat!( + "name: hook\n", + "trigger:\n on: message_posted\n", + "steps:\n - id: s1\n action: send_message\n text: hi\n", + " - id: s2\n action: call_webhook\n url: https://example.com/x\n", + )) + .expect("parse hook"); + assert!(hook.requires_elevated_authority()); + } + + // -- SEC-006: event-path regression (requires Postgres) ---------------- + + async fn setup_db() -> buzz_db::Db { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + buzz_db::Db::new(&buzz_db::DbConfig { + database_url, + ..Default::default() + }) + .await + .expect("connect test DB") + } + + /// Create a community, a channel owned by `creator`, and add `member` as a + /// plain member. Returns `(community, channel)`. + async fn setup_channel(db: &buzz_db::Db, creator: &[u8], member: &[u8]) -> (CommunityId, Uuid) { + let host = format!("sec006-{}.example", Uuid::new_v4().simple()); + let community = match db + .create_community_with_owner(&host, &hex::encode(creator)) + .await + .expect("create community") + { + buzz_db::CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("unexpected community create result: {other:?}"), + }; + db.ensure_user(community, creator) + .await + .expect("creator user"); + db.ensure_user(community, member) + .await + .expect("member user"); + let channel_id = Uuid::new_v4(); + db.create_channel_with_id( + community, + channel_id, + &format!("ch-{}", channel_id.simple()), + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + creator, + None, + ) + .await + .expect("create channel"); + db.add_member( + community, + channel_id, + member, + buzz_db::channel::MemberRole::Member, + Some(creator), + ) + .await + .expect("add member"); + (community, channel_id) + } + + fn message_event(channel_id: Uuid) -> buzz_core::StoredEvent { + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello") + .sign_with_keys(&keys) + .expect("sign"); + buzz_core::StoredEvent::new(event, Some(channel_id)) + } + + /// The event path must stop creating runs the moment the workflow's owner + /// loses channel membership — even while the workflow row is still + /// `enabled` (the disable-on-removal side effect is a separate, relay-side + /// write; this gate must hold on its own). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn on_event_denies_run_after_owner_removed() { + let db = setup_db().await; + let creator = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let member = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let (community, channel_id) = setup_channel(&db, &creator, &member).await; + + let def_json = serde_json::json!({ + "name": "sec006-event", + "trigger": {"on": "message_posted"}, + "steps": [{"id": "s1", "action": "send_message", "text": "hi"}], + "enabled": true, + }) + .to_string(); + let workflow_id = db + .create_workflow( + community, + Some(channel_id), + &member, + "sec006-event", + &def_json, + &[0u8; 32], + ) + .await + .expect("create workflow"); + + let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default())); + + // Owner is an active member: the event fires the workflow. + engine + .on_event(community, &message_event(channel_id)) + .await + .expect("on_event while member"); + let runs = db + .list_workflow_runs(community, workflow_id, 10) + .await + .expect("list runs"); + assert_eq!(runs.len(), 1, "member owner's workflow must fire"); + + // Remove the owner (actor = channel creator, an owner-role member). + db.remove_member(community, channel_id, &member, &creator) + .await + .expect("remove member"); + + // Workflow row is still enabled — only the authority gate stands. + engine + .on_event(community, &message_event(channel_id)) + .await + .expect("on_event after removal"); + let runs = db + .list_workflow_runs(community, workflow_id, 10) + .await + .expect("list runs after removal"); + assert_eq!( + runs.len(), + 1, + "no new run may be created after the owner lost membership" + ); + } + + /// Exfiltration-capable definitions (call_webhook) require the owner to + /// currently hold an elevated role — a plain member's workflow must not + /// fire even though the owner is still an active channel member. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn on_event_denies_webhook_definition_for_plain_member_owner() { + let db = setup_db().await; + let creator = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let member = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let (community, channel_id) = setup_channel(&db, &creator, &member).await; + + let def_json = serde_json::json!({ + "name": "sec006-hook", + "trigger": {"on": "message_posted"}, + "steps": [{"id": "s1", "action": "call_webhook", "url": "https://example.com/x"}], + "enabled": true, + }) + .to_string(); + + // Same definition, two owners: plain member vs channel owner. + let wf_member = db + .create_workflow( + community, + Some(channel_id), + &member, + "hook-member", + &def_json, + &[0u8; 32], + ) + .await + .expect("create member workflow"); + let wf_owner = db + .create_workflow( + community, + Some(channel_id), + &creator, + "hook-owner", + &def_json, + &[1u8; 32], + ) + .await + .expect("create owner workflow"); + + let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default())); + engine + .on_event(community, &message_event(channel_id)) + .await + .expect("on_event"); + + let member_runs = db + .list_workflow_runs(community, wf_member, 10) + .await + .expect("member runs"); + assert!( + member_runs.is_empty(), + "plain member's call_webhook workflow must not fire" + ); + let owner_runs = db + .list_workflow_runs(community, wf_owner, 10) + .await + .expect("owner runs"); + assert_eq!( + owner_runs.len(), + 1, + "channel owner's call_webhook workflow fires" + ); + } } diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 34e8bb19603..9bc79aa48b3 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -147,6 +147,19 @@ pub enum ActionDef { } impl WorkflowDef { + /// True when any step performs an action that can exfiltrate channel data + /// to an arbitrary external destination (`call_webhook`). + /// + /// Definitions with such steps require elevated (owner/admin) channel + /// authority both to save and to run — plain membership is not enough, + /// because a workflow forwards channel content with the *owner's* standing + /// authority long after the save (SEC-006). + pub fn requires_elevated_authority(&self) -> bool { + self.steps + .iter() + .any(|s| matches!(s.action, ActionDef::CallWebhook { .. })) + } + /// Validate the workflow definition. Returns `Err` with a descriptive message /// if any invariant is violated. pub fn validate(&self) -> Result<(), WorkflowError> { diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt index d65c1f837b1..b409f4d9424 100644 --- a/deploy/charts/buzz/templates/NOTES.txt +++ b/deploy/charts/buzz/templates/NOTES.txt @@ -64,7 +64,8 @@ {{- end }} {{- if not .Values.relay.requireMediaGetAuth }} ⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated. - Keep this false only during staged client rollout. + Anyone who learns a media URL/hash can fetch private attachments. Only + use for local development or fully public communities. {{- end }} {{- if not .Values.migrate.autoMigrate }} ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations. diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 6313fd59580..c50a960d26b 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -36,6 +36,34 @@ tests: name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: "true" template: templates/deployment.yaml + # Security default: media GET/HEAD reads must be auth-gated out of the + # box. A private attachment must never be publicly readable by URL/hash + # in an unmodified render. If this assertion fails, someone flipped the + # default — treat that as a security regression, not a config tweak. + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_REQUIRE_MEDIA_GET_AUTH + value: "true" + template: templates/deployment.yaml + + - it: lets an explicit value opt out of media read auth for dev/public deployments + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + relay.requireMediaGetAuth: false + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_REQUIRE_MEDIA_GET_AUTH + value: "false" + template: templates/deployment.yaml - it: lets an explicit value disable huddle audio in a single-replica render set: diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 180afc67462..21548f36518 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -107,10 +107,12 @@ relay: sendBuffer: 1000 requireAuthToken: true requireRelayMembership: true - # Staged rollout gate for authenticated media reads. When true, relay - # GET/HEAD /media/* requires Blossom kind 24242 t=get plus relay membership. - # Keep false until deployed desktop/mobile/CLI clients attach read auth. - requireMediaGetAuth: false + # Authenticated media reads: relay GET/HEAD /media/* requires Blossom + # kind 24242 t=get plus relay membership. Enabled by default so private + # attachments are never publicly readable by URL/hash. Only set false for + # local development or fully public communities — desktop, mobile, and CLI + # clients all attach read auth. + requireMediaGetAuth: true allowNipOaAuth: true pubkeyAllowlist: false corsOrigins: [] diff --git a/desktop/package.json b/desktop/package.json index 6726bfdcadd..adac095a479 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.4.26", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 53550c69b89..599d4d6917e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -59,6 +59,7 @@ export default defineConfig({ "**/video-attachment.spec.ts", "**/spoiler.spec.ts", "**/composer-link-shortcut.spec.ts", + "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", "**/team-mentions.spec.ts", @@ -105,6 +106,7 @@ export default defineConfig({ "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", + "**/inbox-refactor-screenshots.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 5f6f48eb14b..6e44481d578 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -344,7 +344,9 @@ const overrides = new Map([ // absent, so AdapterMissing replaces the misleading NotInstalled. Includes // the deliberate-divergence doc comments; net after the inline preset // entries.push block collapsed into the helper. - ["src-tauri/src/managed_agents/discovery.rs", 1835], + // +6: legacy Goose Windows install dir (%USERPROFILE%\goose) probed in + // common_binary_paths so pre-#2680 standalone installs are discoverable. + ["src-tauri/src/managed_agents/discovery.rs", 1841], // BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm / // delete_and_warm (persist-mutex serialization for concurrent-safe registry // refresh, B-6). Also: id/collision/load/registry tests (from the file base) + @@ -627,7 +629,11 @@ const overrides = new Map([ // return value so the frontend immediately has the updated env. // +1: rebase over main (#2680) — requires_external_cli: false added to // save_custom_harness catalog entry construction (new required field). - ["src-tauri/src/commands/agent_discovery.rs", 2167], + // -359: install command execution (spawn, output drain under timeout, retry + // with backoff, output truncation) extracted to agent_discovery/install_exec.rs + // alongside its tests, matching the managed_node.rs / post_install_verification.rs + // split. The entries above describe the file's history, not its current shape. + ["src-tauri/src/commands/agent_discovery.rs", 1808], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7dcf615c00d..66553ef5954 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -991,6 +991,7 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", "hex", "hmac 0.13.0", @@ -1009,7 +1010,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.4.26" +version = "0.5.0" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d689544688d..324218a49d1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.4.26" +version = "0.5.0" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 84a00433c0b..76f8596caf2 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,12 +1,11 @@ -use std::io::Read; use tauri::State; use crate::{ app_state::AppState, managed_agents::{ command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult, - ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, + DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, + RelayAgentInfo, DEFAULT_ACP_COMMAND, }, nostr_convert, relay::query_relay, @@ -862,72 +861,6 @@ pub(crate) fn install_shell_from( resolved.ok_or_else(|| crate::managed_agents::git_bash::GIT_BASH_INSTALL_HINT.to_string()) } -/// Maximum number of attempts for a transient-looking install command. -const INSTALL_MAX_ATTEMPTS: u32 = 3; - -/// Run an install command, retrying transient failures with backoff. -/// -/// Runtime installs pull artifacts over the network — Goose's `curl … | bash` -/// fetches a native release-asset tarball from GitHub's CDN with no retry of -/// its own, and the npm adapter installs hit the registry. A single blip there -/// currently fails onboarding outright. This retries a command that ran to -/// completion but exited nonzero (the transient-download signature) up to -/// `INSTALL_MAX_ATTEMPTS` times. Failures with no exit code — a timeout or a -/// shell that never spawned — are not retried, since re-running them just costs -/// the user more time without a plausible path to success. -fn run_install_command_with_retry(step: &str, command: &str) -> InstallStepResult { - run_install_with_retry( - INSTALL_MAX_ATTEMPTS, - |_attempt| run_install_command(step, command), - std::thread::sleep, - ) -} - -/// Core retry loop, decoupled from the real command runner and clock so it can -/// be unit-tested without spawning shells or sleeping. `run` receives the -/// 1-based attempt number. -fn run_install_with_retry( - max_attempts: u32, - mut run: impl FnMut(u32) -> InstallStepResult, - mut sleep: impl FnMut(std::time::Duration), -) -> InstallStepResult { - let mut attempt = 1; - loop { - let result = run(attempt); - if result.success || !install_failure_is_retryable(&result) || attempt >= max_attempts { - return if attempt > 1 && !result.success { - annotate_retry_attempts(result, attempt) - } else { - result - }; - } - sleep(install_retry_backoff(attempt)); - attempt += 1; - } -} - -/// Only retry commands that actually ran and exited nonzero — the signature of -/// a transient download failure. A missing exit code means the command timed -/// out or the shell failed to spawn, neither of which a retry is likely to fix. -fn install_failure_is_retryable(result: &InstallStepResult) -> bool { - !result.success && result.exit_code.is_some() -} - -/// Linear backoff: 3s before attempt 2, 6s before attempt 3. -fn install_retry_backoff(attempt: u32) -> std::time::Duration { - std::time::Duration::from_secs(3 * attempt as u64) -} - -/// Prefix the surfaced error so the UI shows the install was retried rather than -/// failed on a single unlucky attempt. -fn annotate_retry_attempts(mut result: InstallStepResult, attempts: u32) -> InstallStepResult { - result.stderr = format!( - "install failed after {attempts} attempts (retried with backoff)\n{}", - result.stderr - ); - result -} - /// Returns `true` when `command` is a Windows-native PowerShell invocation /// (i.e. begins with `powershell.exe`). These commands must NOT be routed /// through Git Bash: the Bash login shell prepends POSIX dirs to PATH, so @@ -1081,179 +1014,9 @@ fn build_install_command(command: &str) -> Result install_shell_command(command) } -fn run_install_command(step: &str, command: &str) -> InstallStepResult { - let mut cmd = match build_install_command(command) { - Ok(cmd) => cmd, - Err(hint) => { - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: "no suitable shell found for install commands".to_string(), - exit_code: None, - hint: Some(hint), - }; - } - }; - - let mut child = match cmd - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(e) => { - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: format!("failed to spawn shell: {e}"), - exit_code: None, - hint: None, - }; - } - }; - - // Drain stdout/stderr on background threads to prevent pipe buffer deadlock. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - - let stdout_thread = std::thread::spawn(move || { - let mut buf = String::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_string(&mut buf); - } - buf - }); - let stderr_thread = std::thread::spawn(move || { - let mut buf = String::new(); - if let Some(mut pipe) = stderr_pipe { - let _ = pipe.read_to_string(&mut buf); - } - buf - }); - - // Save the PID before moving `child` into the wait thread so we can - // kill the process on timeout. - let child_pid = child.id(); - - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let status = child.wait(); - let _ = tx.send(status); - }); - - // 5-minute timeout for install commands. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); - loop { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - // Timeout: kill the child process via its PID, then join all - // threads so nothing leaks. - #[cfg(unix)] - unsafe { - libc::kill(child_pid as i32, libc::SIGTERM); - } - #[cfg(windows)] - { - let _ = crate::managed_agents::taskkill_tree(child_pid); - } - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: "install command timed out after 5 minutes".to_string(), - exit_code: None, - hint: None, - }; - } - - match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { - Ok(Ok(status)) => { - let _ = wait_thread.join(); - let stdout = stdout_thread.join().unwrap_or_default(); - let stderr_raw = stderr_thread.join().unwrap_or_default(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: status.success(), - stdout: truncate_output(stdout), - stderr: truncate_output(stderr_raw), - exit_code: status.code(), - hint: None, - }; - } - Ok(Err(e)) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: format!("failed to check process status: {e}"), - exit_code: None, - hint: None, - }; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - // Still running; loop and check deadline again. - continue; - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - // wait_thread dropped sender without sending — shouldn't happen. - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: "internal error: wait thread disconnected".to_string(), - exit_code: None, - hint: None, - }; - } - } - } -} - -/// Cap output to head + tail to avoid flooding the UI with large error dumps, -/// while preserving the most useful parts of the output. -fn truncate_output(s: String) -> String { - const HEAD: usize = 512; - const TAIL: usize = 1024; - const LIMIT: usize = HEAD + TAIL; - if s.len() <= LIMIT { - return s; - } - let head_end = floor_char_boundary(&s, HEAD); - let tail_start = floor_char_boundary(&s, s.len().saturating_sub(TAIL)); - let omitted = tail_start - head_end; - format!( - "{}\n... ({omitted} bytes omitted) ...\n{}", - &s[..head_end], - &s[tail_start..] - ) -} - -fn floor_char_boundary(s: &str, mut index: usize) -> usize { - index = index.min(s.len()); - while index > 0 && !s.is_char_boundary(index) { - index -= 1; - } - index -} +// ── install command execution ───────────────────────────────────────────────── +mod install_exec; +use install_exec::run_install_command_with_retry; // ── managed Node/npm runtime ────────────────────────────────────────────────── mod managed_node; @@ -2031,128 +1794,6 @@ mod tests { "Goose catalog command must dequote with bare $env: (no backslash before $)" ); } - - // ── install retry ───────────────────────────────────────────────────────── - - /// Build an `InstallStepResult` with just the fields the retry loop reads. - fn step_result(success: bool, exit_code: Option, stderr: &str) -> InstallStepResult { - InstallStepResult { - step: "cli".to_string(), - command: "curl … | bash".to_string(), - success, - stdout: String::new(), - stderr: stderr.to_string(), - exit_code, - hint: None, - } - } - - #[test] - fn test_retryable_only_for_nonzero_exit() { - // Ran to completion but exited nonzero — the transient-download signature. - assert!(install_failure_is_retryable(&step_result( - false, - Some(1), - "" - ))); - // No exit code — timeout or shell-never-spawned; retry won't help. - assert!(!install_failure_is_retryable(&step_result(false, None, ""))); - // Success is never retryable. - assert!(!install_failure_is_retryable(&step_result( - true, - Some(0), - "" - ))); - } - - #[test] - fn test_retry_backoff_is_linear() { - assert_eq!(install_retry_backoff(1), std::time::Duration::from_secs(3)); - assert_eq!(install_retry_backoff(2), std::time::Duration::from_secs(6)); - } - - #[test] - fn test_retry_stops_on_first_success() { - let mut calls = 0; - let mut sleeps = 0; - let result = run_install_with_retry( - 3, - |_| { - calls += 1; - step_result(true, Some(0), "") - }, - |_| sleeps += 1, - ); - assert!(result.success); - assert_eq!(calls, 1, "a first-attempt success must not re-run"); - assert_eq!(sleeps, 0, "no backoff sleep when nothing is retried"); - } - - #[test] - fn test_retry_recovers_after_transient_failure() { - let mut calls = 0; - let result = run_install_with_retry( - 3, - |attempt| { - calls += 1; - // Fail the first attempt with a nonzero exit, then succeed. - step_result(attempt >= 2, Some(if attempt >= 2 { 0 } else { 1 }), "blip") - }, - |_| {}, - ); - assert!(result.success); - assert_eq!(calls, 2, "should retry once then succeed"); - // A recovered install must not carry the retry-failure annotation. - assert!(!result.stderr.contains("attempts")); - } - - #[test] - fn test_retry_does_not_retry_unretryable_failure() { - let mut calls = 0; - let result = run_install_with_retry( - 3, - |_| { - calls += 1; - step_result(false, None, "timed out") - }, - |_| {}, - ); - assert!(!result.success); - assert_eq!(calls, 1, "a failure with no exit code must not be retried"); - assert_eq!( - result.stderr, "timed out", - "unretried failure is unannotated" - ); - } - - #[test] - fn test_retry_exhausts_attempts_and_annotates() { - let mut calls = 0; - let mut sleeps = 0; - let result = run_install_with_retry( - 3, - |_| { - calls += 1; - step_result(false, Some(1), "download failed") - }, - |_| sleeps += 1, - ); - assert!(!result.success); - assert_eq!(calls, 3, "must try exactly max_attempts times"); - assert_eq!( - sleeps, 2, - "backoff sleeps between attempts, not after the last" - ); - assert!( - result.stderr.contains("after 3 attempts"), - "exhausted retries must surface the attempt count, got: {}", - result.stderr - ); - assert!( - result.stderr.contains("download failed"), - "original stderr must be preserved" - ); - } } /// Returns the Windows-only Git Bash prerequisite used by buzz-agent's shell MCP. diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs b/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs new file mode 100644 index 00000000000..63163ceadc5 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_exec.rs @@ -0,0 +1,457 @@ +//! Execution of runtime install commands: spawning the built command, +//! draining its output under a timeout, and retrying transient failures. +//! +//! Command *construction* stays in the parent module (`install_shell_command`, +//! `install_powershell_command`, `build_install_command`); this module owns +//! only what happens once a `Command` exists. + +use std::io::Read; + +use crate::managed_agents::InstallStepResult; + +/// Maximum number of attempts for a transient-looking install command. +const INSTALL_MAX_ATTEMPTS: u32 = 3; + +/// Run an install command, retrying transient failures with backoff. +/// +/// Runtime installs pull artifacts over the network — Goose's `curl … | bash` +/// fetches a native release-asset tarball from GitHub's CDN with no retry of +/// its own, and the npm adapter installs hit the registry. A single blip there +/// currently fails onboarding outright. This retries a command that ran to +/// completion but exited nonzero (the transient-download signature) up to +/// `INSTALL_MAX_ATTEMPTS` times. Failures with no exit code — a timeout or a +/// shell that never spawned — are not retried, since re-running them just costs +/// the user more time without a plausible path to success. +pub(super) fn run_install_command_with_retry(step: &str, command: &str) -> InstallStepResult { + run_install_with_retry( + INSTALL_MAX_ATTEMPTS, + |_attempt| run_install_command(step, command), + std::thread::sleep, + ) +} + +/// Core retry loop, decoupled from the real command runner and clock so it can +/// be unit-tested without spawning shells or sleeping. `run` receives the +/// 1-based attempt number. +fn run_install_with_retry( + max_attempts: u32, + mut run: impl FnMut(u32) -> InstallStepResult, + mut sleep: impl FnMut(std::time::Duration), +) -> InstallStepResult { + let mut attempt = 1; + loop { + let result = run(attempt); + if result.success || !install_failure_is_retryable(&result) || attempt >= max_attempts { + return if attempt > 1 && !result.success { + annotate_retry_attempts(result, attempt) + } else { + result + }; + } + sleep(install_retry_backoff(attempt)); + attempt += 1; + } +} + +/// Only retry commands that actually ran and exited nonzero — the signature of +/// a transient download failure. A missing exit code means the command timed +/// out or the shell failed to spawn, neither of which a retry is likely to fix. +fn install_failure_is_retryable(result: &InstallStepResult) -> bool { + !result.success && result.exit_code.is_some() +} + +/// Linear backoff: 3s before attempt 2, 6s before attempt 3. +fn install_retry_backoff(attempt: u32) -> std::time::Duration { + std::time::Duration::from_secs(3 * attempt as u64) +} + +/// Prefix the surfaced error so the UI shows the install was retried rather than +/// failed on a single unlucky attempt. +fn annotate_retry_attempts(mut result: InstallStepResult, attempts: u32) -> InstallStepResult { + result.stderr = format!( + "install failed after {attempts} attempts (retried with backoff)\n{}", + result.stderr + ); + result +} + +/// Build the install command and point it at a writable working directory. +/// +/// A packaged desktop launch inherits `/` as its working directory, and +/// installers that write relative to the CWD then fail on a read-only root, so +/// they run from Buzz's own default workdir instead (#2245). +/// +/// This is the only command builder [`run_install_command`] calls, so anything +/// it spawns is guaranteed to carry the workdir — which is what makes the +/// working directory assertable without spawning a real login shell. +fn prepare_install_command(command: &str) -> Result { + let mut cmd = super::build_install_command(command)?; + if let Some(workdir) = crate::managed_agents::default_agent_workdir() { + cmd.current_dir(workdir); + } + Ok(cmd) +} + +fn run_install_command(step: &str, command: &str) -> InstallStepResult { + let mut cmd = match prepare_install_command(command) { + Ok(cmd) => cmd, + Err(hint) => { + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "no suitable shell found for install commands".to_string(), + exit_code: None, + hint: Some(hint), + }; + } + }; + + let mut child = match cmd + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(e) => { + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: format!("failed to spawn shell: {e}"), + exit_code: None, + hint: None, + }; + } + }; + + // Drain stdout/stderr on background threads to prevent pipe buffer deadlock. + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_thread = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut pipe) = stdout_pipe { + let _ = pipe.read_to_string(&mut buf); + } + buf + }); + let stderr_thread = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut pipe) = stderr_pipe { + let _ = pipe.read_to_string(&mut buf); + } + buf + }); + + // Save the PID before moving `child` into the wait thread so we can + // kill the process on timeout. + let child_pid = child.id(); + + let (tx, rx) = std::sync::mpsc::channel(); + let wait_thread = std::thread::spawn(move || { + let status = child.wait(); + let _ = tx.send(status); + }); + + // 5-minute timeout for install commands. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + // Timeout: kill the child process via its PID, then join all + // threads so nothing leaks. + #[cfg(unix)] + unsafe { + libc::kill(child_pid as i32, libc::SIGTERM); + } + #[cfg(windows)] + { + let _ = crate::managed_agents::taskkill_tree(child_pid); + } + drop(rx); + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "install command timed out after 5 minutes".to_string(), + exit_code: None, + hint: None, + }; + } + + match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { + Ok(Ok(status)) => { + let _ = wait_thread.join(); + let stdout = stdout_thread.join().unwrap_or_default(); + let stderr_raw = stderr_thread.join().unwrap_or_default(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: status.success(), + stdout: truncate_output(stdout), + stderr: truncate_output(stderr_raw), + exit_code: status.code(), + hint: None, + }; + } + Ok(Err(e)) => { + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: format!("failed to check process status: {e}"), + exit_code: None, + hint: None, + }; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Still running; loop and check deadline again. + continue; + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + // wait_thread dropped sender without sending — shouldn't happen. + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "internal error: wait thread disconnected".to_string(), + exit_code: None, + hint: None, + }; + } + } + } +} + +/// Cap output to head + tail to avoid flooding the UI with large error dumps, +/// while preserving the most useful parts of the output. +fn truncate_output(s: String) -> String { + const HEAD: usize = 512; + const TAIL: usize = 1024; + const LIMIT: usize = HEAD + TAIL; + if s.len() <= LIMIT { + return s; + } + let head_end = floor_char_boundary(&s, HEAD); + let tail_start = floor_char_boundary(&s, s.len().saturating_sub(TAIL)); + let omitted = tail_start - head_end; + format!( + "{}\n... ({omitted} bytes omitted) ...\n{}", + &s[..head_end], + &s[tail_start..] + ) +} + +fn floor_char_boundary(s: &str, mut index: usize) -> usize { + index = index.min(s.len()); + while index > 0 && !s.is_char_boundary(index) { + index -= 1; + } + index +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── install retry ───────────────────────────────────────────────────────── + + /// Build an `InstallStepResult` with just the fields the retry loop reads. + fn step_result(success: bool, exit_code: Option, stderr: &str) -> InstallStepResult { + InstallStepResult { + step: "cli".to_string(), + command: "curl … | bash".to_string(), + success, + stdout: String::new(), + stderr: stderr.to_string(), + exit_code, + hint: None, + } + } + + #[test] + fn test_retryable_only_for_nonzero_exit() { + // Ran to completion but exited nonzero — the transient-download signature. + assert!(install_failure_is_retryable(&step_result( + false, + Some(1), + "" + ))); + // No exit code — timeout or shell-never-spawned; retry won't help. + assert!(!install_failure_is_retryable(&step_result(false, None, ""))); + // Success is never retryable. + assert!(!install_failure_is_retryable(&step_result( + true, + Some(0), + "" + ))); + } + + #[test] + fn test_retry_backoff_is_linear() { + assert_eq!(install_retry_backoff(1), std::time::Duration::from_secs(3)); + assert_eq!(install_retry_backoff(2), std::time::Duration::from_secs(6)); + } + + #[test] + fn test_retry_stops_on_first_success() { + let mut calls = 0; + let mut sleeps = 0; + let result = run_install_with_retry( + 3, + |_| { + calls += 1; + step_result(true, Some(0), "") + }, + |_| sleeps += 1, + ); + assert!(result.success); + assert_eq!(calls, 1, "a first-attempt success must not re-run"); + assert_eq!(sleeps, 0, "no backoff sleep when nothing is retried"); + } + + #[test] + fn test_retry_recovers_after_transient_failure() { + let mut calls = 0; + let result = run_install_with_retry( + 3, + |attempt| { + calls += 1; + // Fail the first attempt with a nonzero exit, then succeed. + step_result(attempt >= 2, Some(if attempt >= 2 { 0 } else { 1 }), "blip") + }, + |_| {}, + ); + assert!(result.success); + assert_eq!(calls, 2, "should retry once then succeed"); + // A recovered install must not carry the retry-failure annotation. + assert!(!result.stderr.contains("attempts")); + } + + #[test] + fn test_retry_does_not_retry_unretryable_failure() { + let mut calls = 0; + let result = run_install_with_retry( + 3, + |_| { + calls += 1; + step_result(false, None, "timed out") + }, + |_| {}, + ); + assert!(!result.success); + assert_eq!(calls, 1, "a failure with no exit code must not be retried"); + assert_eq!( + result.stderr, "timed out", + "unretried failure is unannotated" + ); + } + + #[test] + fn test_retry_exhausts_attempts_and_annotates() { + let mut calls = 0; + let mut sleeps = 0; + let result = run_install_with_retry( + 3, + |_| { + calls += 1; + step_result(false, Some(1), "download failed") + }, + |_| sleeps += 1, + ); + assert!(!result.success); + assert_eq!(calls, 3, "must try exactly max_attempts times"); + assert_eq!( + sleeps, 2, + "backoff sleeps between attempts, not after the last" + ); + assert!( + result.stderr.contains("after 3 attempts"), + "exhausted retries must surface the attempt count, got: {}", + result.stderr + ); + assert!( + result.stderr.contains("download failed"), + "original stderr must be preserved" + ); + } + + // ── install working directory ───────────────────────────────────────────── + + /// Every install child must run from Buzz's writable default workdir. A + /// packaged launch inherits `/`, where installers that write relative to + /// the CWD fail on a read-only root (#2245). + /// + /// Asserts the prepared `Command` rather than spawning one: `run_install_command` + /// would start a real login shell, which is neither hermetic nor fast. + #[test] + fn test_prepared_install_command_uses_default_workdir() { + let expected = crate::managed_agents::default_agent_workdir() + .expect("a default workdir must resolve on any test host"); + + let cmd = prepare_install_command("echo test").expect("install shell must resolve"); + + assert_eq!(cmd.get_current_dir(), Some(expected.as_path())); + } + + // ── output truncation ───────────────────────────────────────────────────── + + /// Output within the cap is passed through byte-for-byte — no marker, no loss. + #[test] + fn test_truncate_output_leaves_short_output_untouched() { + let short = "a".repeat(1536); + + assert_eq!(truncate_output(short.clone()), short); + } + + /// Over the cap, both ends survive and the middle is replaced by a marker + /// naming the omitted byte count — the head keeps the command's opening + /// context and the tail keeps the error that usually trails. + #[test] + fn test_truncate_output_keeps_head_and_tail_with_marker() { + let input = format!( + "{}{}{}", + "H".repeat(512), + "M".repeat(4000), + "T".repeat(1024) + ); + + let out = truncate_output(input); + + assert!(out.starts_with(&"H".repeat(512))); + assert!(out.ends_with(&"T".repeat(1024))); + assert!( + out.contains("... (4000 bytes omitted) ..."), + "marker must name the omitted byte count, got: {out}" + ); + } + + /// Truncation must not split a multi-byte character. Cutting mid-codepoint + /// would panic on the slice; the boundary floor prevents it. + #[test] + fn test_truncate_output_does_not_split_multibyte_characters() { + // "é" is 2 bytes, so every candidate cut index lands mid-character. + let input = "é".repeat(4000); + + let out = truncate_output(input); + + assert!(out.contains("bytes omitted"), "input must exceed the cap"); + assert!(!out.contains('\u{fffd}'), "no replacement chars: {out}"); + } +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index 2d7e13d1d76..72108f02910 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -5,7 +5,7 @@ use std::{io::Read, io::Write}; use crate::managed_agents::{is_npm_global_install, InstallStepResult}; -const MANAGED_NODE_VERSION: &str = "v24.11.0"; +const MANAGED_NODE_VERSION: &str = "v24.18.0"; const MANAGED_NODE_MAX_BYTES: u64 = 90 * 1024 * 1024; #[derive(Debug, Clone, Copy)] @@ -18,43 +18,43 @@ struct ManagedNodeArtifact { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "darwin-arm64", - filename: "node-v24.11.0-darwin-arm64.tar.gz", - sha256: "0be2ab2816a4fa02d1acff014a434f29f56d8d956f5af6a98b70ced6c5f4d201", + filename: "node-v24.18.0-darwin-arm64.tar.gz", + sha256: "e1a97e14c99c803e96c7339403282ea05a499c32f8d83defe9ef5ec66f979ed1", }); #[cfg(all(target_os = "macos", target_arch = "x86_64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "darwin-x64", - filename: "node-v24.11.0-darwin-x64.tar.gz", - sha256: "3884671e87f46f773832d98a0a6cabcc5ec4f637084f0f3515b69e66ea27f2f1", + filename: "node-v24.18.0-darwin-x64.tar.gz", + sha256: "dfd0dbd3e721503434df7b7205e719f61b3a3a31b2bcf9729b8b91fea240f080", }); #[cfg(all(target_os = "linux", target_arch = "x86_64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "linux-x64", - filename: "node-v24.11.0-linux-x64.tar.gz", - sha256: "b3c071cdf47aab867c3b2aa287257df12ec5d7c962bf922b32fd33226c4295fd", + filename: "node-v24.18.0-linux-x64.tar.gz", + sha256: "783130984963db7ba9cbd01089eaf2c2efb055c7c1693c943174b967b3050cb8", }); #[cfg(all(target_os = "linux", target_arch = "aarch64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "linux-arm64", - filename: "node-v24.11.0-linux-arm64.tar.gz", - sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d", + filename: "node-v24.18.0-linux-arm64.tar.gz", + sha256: "6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508", }); #[cfg(all(target_os = "windows", target_arch = "x86_64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "win-x64", - filename: "node-v24.11.0-win-x64.zip", - sha256: "1054540bce22b54ec7e50ebc078ec5d090700a77657607a58f6a64df21f49fdd", + filename: "node-v24.18.0-win-x64.zip", + sha256: "0ae68406b42d7725661da979b1403ec9926da205c6770827f33aac9d8f26e821", }); #[cfg(all(target_os = "windows", target_arch = "aarch64"))] const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { platform: "win-arm64", - filename: "node-v24.11.0-win-arm64.zip", - sha256: "12d3b1aa9696b7411e115a4fa2aef57f95560b5ee16bb62cd69843e535ec72be", + filename: "node-v24.18.0-win-arm64.zip", + sha256: "f274669adb93b1fd0fbf8f21fd078609e9dcc84333d4f2718d2dde3f9a161a01", }); #[cfg(not(any( @@ -615,9 +615,9 @@ mod tests { #[test] fn test_validate_zip_accepts_normal_entries() { let tmp = make_zip_with_entries(&[ - "node-v24.11.0-win-x64/node.exe", - "node-v24.11.0-win-x64/npm.cmd", - "node-v24.11.0-win-x64/npm", + "node-v24.18.0-win-x64/node.exe", + "node-v24.18.0-win-x64/npm.cmd", + "node-v24.18.0-win-x64/npm", ]); let file = std::fs::File::open(tmp.path()).unwrap(); let archive = zip::ZipArchive::new(file).unwrap(); @@ -677,7 +677,7 @@ mod tests { #[test] fn test_validate_zip_rejects_backslash_traversal() { // Path traversal using Windows separator — must reject on every host. - let tmp = make_zip_with_entries(&["node-v24.11.0-win-x64\\..\\..\\evil"]); + let tmp = make_zip_with_entries(&["node-v24.18.0-win-x64\\..\\..\\evil"]); let file = std::fs::File::open(tmp.path()).unwrap(); let archive = zip::ZipArchive::new(file).unwrap(); let err = validate_managed_node_zip_entries(&archive).unwrap_err(); diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b5ebeca4f6..16272ac28b3 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -45,52 +45,15 @@ pub(super) fn retain_managed_agent_pending( state: &AppState, record: &ManagedAgentRecord, ) { - use crate::managed_agents::{ - agent_events::{agent_event_content, build_agent_event}, - persona_events::monotonic_created_at, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; + use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db}; let result = (|| -> Result<(), String> { let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - // The published content is the opt-IN projection JSON, independent of - // signing and created_at. Compute it once to drive the no-republish - // guard without signing twice. - let content = serde_json::to_string(&agent_event_content(record)) - .map_err(|e| format!("failed to serialize managed-agent content: {e}"))?; - let (owner_pubkey, event) = { - let keys = state.signing_keys()?; - let owner_pubkey = keys.public_key().to_hex(); - let existing = - get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; - // Skip re-publishing when the projection is unchanged: a start/stop - // or any edit that touched only excluded runtime/local fields - // produces an identical projection, so it is a no-op — operational - // churn never re-enqueues a publish. - if existing.as_ref().is_some_and(|row| row.content == content) { - return Ok(()); - } - // Monotonic created_at: bump past the retained head (NIP-AP step 3). - let event = build_agent_event(record)? - .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign managed-agent event: {e}"))?; - (owner_pubkey, event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey, - d_tag: record.pubkey.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) + let keys = state.signing_keys()?; + // Shared engine with the boot-time reconcile: projection content diff + // (no republish for runtime-only churn) + monotonic created_at bump + // past the retained head (NIP-AP step 3). + retain_agent_record(&conn, &keys, record).map(|_| ()) })(); if let Err(e) = result { eprintln!("buzz-desktop: agent-retain: {e}"); diff --git a/desktop/src-tauri/src/commands/join_policy.rs b/desktop/src-tauri/src/commands/join_policy.rs new file mode 100644 index 00000000000..d2a357c4f94 --- /dev/null +++ b/desktop/src-tauri/src/commands/join_policy.rs @@ -0,0 +1,193 @@ +use futures_util::StreamExt; +use serde_json::Value; +use std::time::Duration; +use url::Url; + +// Each relay policy document is capped at 256 KiB before JSON encoding. Four +// MiB covers two maximally escaped documents plus the response envelope. +const MAX_JOIN_POLICY_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +const JOIN_POLICY_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +fn join_policy_url(relay_url: &str) -> Result { + let mut url = Url::parse(relay_url.trim()).map_err(|_| "invalid relay URL".to_string())?; + let http_scheme = match url.scheme() { + "wss" => "https", + "ws" => "http", + _ => return Err("relay URL must use ws:// or wss://".to_string()), + }; + url.set_scheme(http_scheme) + .map_err(|_| "invalid relay URL scheme".to_string())?; + + if !url.username().is_empty() || url.password().is_some() { + return Err("relay URL must not contain credentials".to_string()); + } + + let base_path = url.path().trim_end_matches('/'); + url.set_path(&format!("{base_path}/api/join-policy")); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +/// Fetch an arbitrary relay's optional join policy through native networking. +#[tauri::command] +pub async fn fetch_join_policy(relay_url: String) -> Result, String> { + let url = join_policy_url(&relay_url)?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("failed to build join policy client: {error}"))?; + let response = client + .get(url) + .timeout(JOIN_POLICY_REQUEST_TIMEOUT) + .send() + .await + .map_err(|error| format!("join policy request failed: {error}"))?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + let body = read_join_policy_json(response).await?; + Ok(body + .get("policy") + .filter(|policy| !policy.is_null()) + .cloned()) +} + +async fn read_join_policy_json(response: reqwest::Response) -> Result { + if response + .content_length() + .is_some_and(|length| length > MAX_JOIN_POLICY_RESPONSE_BYTES as u64) + { + return Err("relay returned oversized join policy".to_string()); + } + + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("reading join policy failed: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > MAX_JOIN_POLICY_RESPONSE_BYTES { + return Err("relay returned oversized join policy".to_string()); + } + bytes.extend_from_slice(&chunk); + } + + serde_json::from_slice(&bytes).map_err(|_| "relay returned malformed join policy".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{Body, Bytes}, + http::Response, + response::Redirect, + routing::get, + Json, Router, + }; + use std::convert::Infallible; + + async fn test_relay(router: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("ws://{address}") + } + + #[test] + fn converts_relay_urls_to_join_policy_urls() { + assert_eq!( + join_policy_url("wss://relay.example.com/") + .unwrap() + .as_str(), + "https://relay.example.com/api/join-policy" + ); + assert_eq!( + join_policy_url("ws://localhost:3000/base") + .unwrap() + .as_str(), + "http://localhost:3000/base/api/join-policy" + ); + } + + #[test] + fn rejects_non_relay_schemes_and_credentials() { + assert!(join_policy_url("https://relay.example.com").is_err()); + assert!(join_policy_url("wss://user:secret@relay.example.com").is_err()); + } + + #[tokio::test] + async fn reads_an_optional_policy_without_webview_cors() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + Json(serde_json::json!({ + "policy": { + "terms_markdown": "# Terms", + "age_attestation_required": true, + "version": "v1" + } + })) + }), + )) + .await; + + let policy = fetch_join_policy(relay_url).await.unwrap().unwrap(); + assert_eq!(policy["version"], "v1"); + assert_eq!(policy["age_attestation_required"], true); + } + + #[tokio::test] + async fn refuses_join_policy_redirects() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { Redirect::temporary("http://127.0.0.1:1/private") }), + )) + .await; + + assert_eq!(fetch_join_policy(relay_url).await.unwrap_err(), "HTTP 307"); + } + + #[tokio::test] + async fn rejects_declared_oversized_join_policy() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + Response::builder() + .body(Body::from(vec![b'x'; MAX_JOIN_POLICY_RESPONSE_BYTES + 1])) + .unwrap() + }), + )) + .await; + + assert_eq!( + fetch_join_policy(relay_url).await.unwrap_err(), + "relay returned oversized join policy" + ); + } + + #[tokio::test] + async fn rejects_chunked_oversized_join_policy() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + let chunk = Bytes::from(vec![b'x'; MAX_JOIN_POLICY_RESPONSE_BYTES + 1]); + Body::from_stream(futures_util::stream::once(async move { + Ok::<_, Infallible>(chunk) + })) + }), + )) + .await; + + assert_eq!( + fetch_join_policy(relay_url).await.unwrap_err(), + "relay returned oversized join policy" + ); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index afe94cdfe62..b7c37bec3df 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -138,7 +138,14 @@ pub async fn get_feed( }) } -fn build_search_messages_filter(q: &str, cap: u32, channel_id: Option<&str>) -> serde_json::Value { +fn build_search_messages_filter( + q: &str, + cap: u32, + channel_id: Option<&str>, + authors: Option<&[String]>, + since: Option, + until: Option, +) -> serde_json::Value { let mut filter = serde_json::Map::new(); filter.insert( "kinds".to_string(), @@ -153,6 +160,25 @@ fn build_search_messages_filter(q: &str, cap: u32, channel_id: Option<&str>) -> if let Some(cid) = channel_id { filter.insert("#h".to_string(), serde_json::json!([cid])); } + // Optional operators from the desktop search parser (#2853). The relay + // already maps authors/since/until onto FTS; search remains never the + // access boundary (hits are refetched and re-authorized). + if let Some(authors) = authors { + let cleaned: Vec<&str> = authors + .iter() + .map(|a| a.trim()) + .filter(|a| !a.is_empty()) + .collect(); + if !cleaned.is_empty() { + filter.insert("authors".to_string(), serde_json::json!(cleaned)); + } + } + if let Some(since) = since { + filter.insert("since".to_string(), serde_json::json!(since)); + } + if let Some(until) = until { + filter.insert("until".to_string(), serde_json::json!(until)); + } serde_json::Value::Object(filter) } @@ -161,10 +187,20 @@ pub async fn search_messages( q: String, limit: Option, channel_id: Option, + authors: Option>, + since: Option, + until: Option, state: State<'_, AppState>, ) -> Result { let cap = limit.unwrap_or(20).min(100); - let filter = build_search_messages_filter(&q, cap, channel_id.as_deref()); + let filter = build_search_messages_filter( + &q, + cap, + channel_id.as_deref(), + authors.as_deref(), + since, + until, + ); let events = query_relay(&state, &[filter]).await?; Ok(nostr_convert::search_response_from_events(&events)) diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index 3c08f0f1130..a907a3dff1d 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -80,12 +80,38 @@ fn managed_agent_message_builder_rejects_invalid_mentions() { } #[test] fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() { - let filter = build_search_messages_filter(" pro ", 12, Some("channel-1")); + let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"), None, None, None); assert_eq!(filter["search"], serde_json::json!("pro")); assert_eq!(filter["search_mode"], serde_json::json!("prefix")); assert_eq!(filter["limit"], serde_json::json!(12)); assert_eq!(filter["#h"], serde_json::json!(["channel-1"])); + assert!(filter.get("authors").is_none()); + assert!(filter.get("since").is_none()); + assert!(filter.get("until").is_none()); +} + +#[test] +fn search_messages_filter_emits_operator_fields() { + let authors = + vec!["aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899".to_string()]; + let filter = build_search_messages_filter( + "deploy", + 20, + Some("channel-uuid"), + Some(&authors), + Some(1_700_000_000), + Some(1_700_086_400), + ); + + assert_eq!(filter["search"], serde_json::json!("deploy")); + assert_eq!(filter["#h"], serde_json::json!(["channel-uuid"])); + assert_eq!( + filter["authors"], + serde_json::json!(["aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"]) + ); + assert_eq!(filter["since"], serde_json::json!(1_700_000_000)); + assert_eq!(filter["until"], serde_json::json!(1_700_086_400)); } #[test] diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a048ad24af5..1c89ee4f771 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ mod export_util; mod global_agent_config; mod identity; mod identity_archive; +mod join_policy; mod legacy_storage; mod link_preview; pub(crate) mod media; @@ -78,6 +79,7 @@ pub use engrams::*; pub use global_agent_config::*; pub use identity::*; pub use identity_archive::*; +pub use join_policy::*; pub use legacy_storage::*; pub use link_preview::*; pub use media::*; diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 6437d4b1a88..2f1f292f5eb 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -272,6 +272,15 @@ pub async fn update_persona( if agents_modified { save_managed_agents(&app, &records)?; + // Keep retained kind:30177 identity records in lockstep with + // the rename (#2423): `record.name` is part of the published + // identity projection, so skipping this strands the relay on + // the stale name→pubkey binding until the next boot reconcile. + // Avatar-only edits are excluded — the avatar is not in the + // projection, so retaining would be a guaranteed no-op. + for record in records.iter().filter(|r| renamed.contains(&r.pubkey)) { + super::agents::retain_managed_agent_pending(&app, &state, record); + } } params diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cf2e5b36b08..0af96d9d92b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -896,6 +896,7 @@ pub fn run() { validate_repos_dir, get_active_workspace, fetch_workspace_icon, + fetch_join_policy, set_prevent_sleep_active, get_agent_memory, relay_reconnect_hook, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 97498c8c3da..71e689330fd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -58,6 +58,12 @@ fn common_binary_paths() -> &'static [PathBuf] { .join("bin"), ); } + // Goose's legacy Windows installer (superseded by #2680) unpacked + // to %USERPROFILE%\goose\goose.exe, which is on no standard PATH — + // without this probe those installs stay permanently undiscovered. + if let Some(profile) = std::env::var_os("USERPROFILE") { + paths.push(PathBuf::from(profile).join("goose")); + } } paths }) diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs index 2f6b038deb7..0795bb2345e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs @@ -1,5 +1,31 @@ use crate::managed_agents::discovery::{clear_resolve_cache, resolve_command}; +/// The legacy Goose Windows installer wrote `%USERPROFILE%\goose\goose.exe`, +/// a directory on no standard PATH. `resolve_command_uncached` finds binaries +/// outside PATH only by scanning `common_binary_paths()`, so that directory +/// must appear there or those installs stay undiscovered (#2239 residual). +/// +/// Asserts the probe list rather than a planted binary: `common_binary_paths` +/// is a process-lifetime `OnceLock`, so a test cannot re-seed `USERPROFILE` +/// deterministically, and planting an executable under the real user profile +/// is not an acceptable test side effect. +#[cfg(windows)] +#[test] +fn common_binary_paths_probes_legacy_goose_install_dir() { + use std::path::PathBuf; + + let profile = std::env::var_os("USERPROFILE").expect("USERPROFILE is always set on Windows"); + let legacy_dir = PathBuf::from(profile).join("goose"); + + let probed = super::super::common_binary_paths(); + + assert!( + probed.contains(&legacy_dir), + "legacy Goose install dir {} must be probed, got: {probed:?}", + legacy_dir.display() + ); +} + #[cfg(unix)] #[test] fn resolve_command_prefers_buzz_managed_npm_shim_over_path() { diff --git a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs index e4b4c36967b..ffcdea8181d 100644 --- a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs +++ b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs @@ -4,7 +4,7 @@ pub(crate) fn buzz_managed_npm_prefix() -> Option { dirs::data_dir().map(|dir| dir.join("Buzz").join("node-tools")) } -const BUZZ_MANAGED_NODE_VERSION: &str = "v24.11.0"; +const BUZZ_MANAGED_NODE_VERSION: &str = "v24.18.0"; pub(crate) fn buzz_managed_node_root() -> Option { dirs::data_dir().map(|dir| dir.join("Buzz").join("runtimes").join("node")) diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index dc73bc97392..315e558c549 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -79,8 +79,6 @@ pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Re return Ok(0); } - let owner_pubkey = keys.public_key().to_hex(); - let db_path = base_dir.join("retention.db"); let conn = open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; @@ -94,46 +92,66 @@ pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Re continue; } - let existing = - get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; - - // Build the event first and compare ITS content, so the comparison and - // the retained row share one serialization of the projection (mirrors - // `migrate_personas_in_dir`). Serializing the projection independently - // here would silently diverge if `build_agent_event` ever changed how - // it serializes — republishing every agent every boot. Content is - // timestamp-independent, so the monotonic bump below never forces a - // spurious republish; an unchanged agent is still a true no-op. - let event = build_agent_event(record)? - .custom_created_at(monotonic_created_at( - existing.as_ref().map(|row| row.created_at), - )) - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event for '{}': {e}", record.name))?; - - let content = event.content.clone(); - if existing.as_ref().is_some_and(|row| row.content == content) { - continue; + if retain_agent_record(&conn, keys, record)? { + reconciled += 1; } - - retain_event( - &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey.clone(), - d_tag: record.pubkey.clone(), - content, - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - .map_err(|e| format!("failed to retain '{}': {e}", record.name))?; - reconciled += 1; } Ok(reconciled) } +/// Retain `record`'s kind:30177 identity record, marking it `pending_sync` +/// for the flush loop, when its projection differs from the retained head. +/// Returns `Ok(true)` when a row was (re)written and `Ok(false)` when the +/// retained content already matches (a true no-op — no `pending_sync` churn). +/// +/// This is the single content-diff + monotonic-bump engine shared by the +/// boot-time reconcile above and the interactive edit paths +/// (`retain_managed_agent_pending`, persona-rename propagation). Every +/// mutation of an agent's published identity must go through it so the +/// retained record can never silently drift from `managed-agents.json`. +pub(crate) fn retain_agent_record( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + record: &ManagedAgentRecord, +) -> Result { + let owner_pubkey = keys.public_key().to_hex(); + let existing = get_retained_event(conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + + // Build the event first and compare ITS content, so the comparison and + // the retained row share one serialization of the projection (mirrors + // `migrate_personas_in_dir`). Serializing the projection independently + // here would silently diverge if `build_agent_event` ever changed how + // it serializes — republishing every agent every boot. Content is + // timestamp-independent, so the monotonic bump below never forces a + // spurious republish; an unchanged agent is still a true no-op. + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event for '{}': {e}", record.name))?; + + let content = event.content.clone(); + if existing.as_ref().is_some_and(|row| row.content == content) { + return Ok(false); + } + + retain_event( + conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: record.pubkey.clone(), + content, + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .map_err(|e| format!("failed to retain '{}': {e}", record.name))?; + Ok(true) +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index 5af05f99d6e..c9269dbf002 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -300,3 +300,103 @@ fn slimming_republish_wave_is_one_time() { "second boot must be a no-op (idempotence)" ); } + +// ── retain_agent_record (interactive-edit engine) ──────────────────────────── +// +// #2423: renaming an agent must re-retain its kind:30177 identity record +// IMMEDIATELY, not at the next boot-time reconcile. These tests pin the shared +// engine both the boot reconcile and the interactive edit paths +// (`retain_managed_agent_pending`, persona-rename propagation) run on. + +/// A rename re-retains the identity record under the SAME coordinate (the +/// agent pubkey) with the new name, queued for publish, with a created_at +/// strictly past the retained head so the relay's replaceable-event rule +/// accepts it. Without this, the relay keeps the old name→pubkey binding +/// until the next restart — the identity desync in #2423. +#[test] +fn rename_re_retains_identity_record_with_new_name() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let owner = keys.public_key().to_hex(); + let pubkey = "9".repeat(64); + let mut record = sample_record(&pubkey, "Fizz"); + + assert!(retain_agent_record(&conn, &keys, &record).unwrap()); + let first = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &pubkey) + .unwrap() + .unwrap(); + // Simulate the flush loop confirming the initial publish. + mark_synced( + &conn, + first.kind, + &first.pubkey, + &first.d_tag, + first.created_at, + &first.content, + ) + .unwrap(); + + record.name = "Spark".to_string(); + assert!( + retain_agent_record(&conn, &keys, &record).unwrap(), + "a renamed record must re-retain its identity record" + ); + + let row = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &pubkey) + .unwrap() + .unwrap(); + assert_eq!(row.d_tag, pubkey, "coordinate stays keyed by agent pubkey"); + assert!( + row.content.contains("Spark"), + "retained identity record must carry the new name" + ); + assert!( + !row.content.contains("Fizz"), + "the stale name must not survive the rename" + ); + assert!(row.pending_sync, "a rename must queue a republish"); + assert!( + row.created_at > first.created_at, + "created_at must bump past the retained head (replaceable-event rule)" + ); +} + +/// An unchanged record is a true no-op: no rewrite, no `pending_sync` churn. +/// This is what lets every edit path call the engine unconditionally. +#[test] +fn retain_agent_record_is_noop_when_unchanged() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let pubkey = "8".repeat(64); + let record = sample_record(&pubkey, "steady-agent"); + + assert!(retain_agent_record(&conn, &keys, &record).unwrap()); + let row = get_retained_event( + &conn, + KIND_MANAGED_AGENT, + &keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + mark_synced( + &conn, + row.kind, + &row.pubkey, + &row.d_tag, + row.created_at, + &row.content, + ) + .unwrap(); + + assert!( + !retain_agent_record(&conn, &keys, &record).unwrap(), + "an unchanged projection must not re-retain" + ); + assert!( + get_pending_sync(&conn).unwrap().is_empty(), + "no pending_sync churn for an unchanged record" + ); +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 1444dd3acfa..996299c5893 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "MAC Workspace", - "version": "0.4.26", + "version": "0.5.0", "identifier": "com.macsurfacing.workspace", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index ba0f94a815b..8d55d59856e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -97,6 +97,7 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; + const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -106,7 +107,6 @@ export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); - const communitiesHook = useCommunities(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); diff --git a/desktop/src/app/useThreadActivityFeedItems.test.mjs b/desktop/src/app/useThreadActivityFeedItems.test.mjs index 6c017028ca4..54d36dc1e88 100644 --- a/desktop/src/app/useThreadActivityFeedItems.test.mjs +++ b/desktop/src/app/useThreadActivityFeedItems.test.mjs @@ -113,3 +113,18 @@ test("channel-presence fence applies before mute filter — unknown channel + mu assert.deepEqual(items, []); }); + +test("top-level DM activity is included in Activity", () => { + const dmItem = threadActivityItem({ + id: "agent-dm-reply", + tags: [["h", CHANNEL_ID]], + }); + const channels = [{ id: CHANNEL_ID, name: "Agent DM", channelType: "dm" }]; + + assert.deepEqual( + buildThreadActivityFeedItems([dmItem], new Set(), channels).map( + (item) => item.id, + ), + ["agent-dm-reply"], + ); +}); diff --git a/desktop/src/app/useThreadActivityFeedItems.ts b/desktop/src/app/useThreadActivityFeedItems.ts index af0c50ce48a..b14eb51327f 100644 --- a/desktop/src/app/useThreadActivityFeedItems.ts +++ b/desktop/src/app/useThreadActivityFeedItems.ts @@ -17,7 +17,8 @@ export function buildThreadActivityFeedItems( // is present in the active community's channel set. Rows persisted under // a different relay's scope key should never reach this function, but // this filter is the last line of defense against cross-community leaks. - if (channelById.get(item.channelId) === undefined) return false; + const channel = channelById.get(item.channelId); + if (channel === undefined) return false; const rootId = getThreadReference(item.tags).rootId; return !rootId || !mutedRootIds.has(rootId); }) diff --git a/desktop/src/features/agents/knownAgentPubkeys.test.mjs b/desktop/src/features/agents/knownAgentPubkeys.test.mjs index 2b7b3e18afc..d58b7f5dabc 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.test.mjs +++ b/desktop/src/features/agents/knownAgentPubkeys.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mergeKnownAgentPubkeys } from "./knownAgentPubkeys.ts"; +import { + mergeKnownAgentPubkeys, + mergeOwnedAgentPubkeys, +} from "./knownAgentPubkeys.ts"; const MANAGED = "1111111111111111111111111111111111111111111111111111111111111111"; @@ -34,3 +37,26 @@ test("normalisesCaseAndWhitespace_dedupingAcrossSources", () => { assert.deepEqual([...merged], [MANAGED]); }); + +test("owned agents include managed and profile-declared agents", () => { + const merged = mergeOwnedAgentPubkeys( + [{ pubkey: MANAGED }], + { + [RELAY]: { ownerPubkey: " owner " }, + other: { ownerPubkey: "somebody-else" }, + }, + "OWNER", + ); + + assert.deepEqual([...merged].sort(), [MANAGED, RELAY].sort()); +}); + +test("owned agents exclude agents controlled by somebody else", () => { + const merged = mergeOwnedAgentPubkeys( + undefined, + { [RELAY]: { ownerPubkey: "somebody-else" } }, + "owner", + ); + + assert.equal(merged.size, 0); +}); diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts index 978509af096..70cbac68fdb 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.ts +++ b/desktop/src/features/agents/knownAgentPubkeys.ts @@ -22,6 +22,34 @@ export function mergeKnownAgentPubkeys( return pubkeys; } +/** Agent identities controlled by the current user. */ +export function mergeOwnedAgentPubkeys( + managedAgents: readonly { pubkey: string }[] | undefined, + profiles: + | Readonly> + | undefined, + currentPubkey: string | null | undefined, +): ReadonlySet { + const pubkeys = new Set(); + for (const agent of managedAgents ?? []) { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + + if (!currentPubkey) return pubkeys; + + const ownerPubkey = normalizePubkey(currentPubkey); + for (const [pubkey, profile] of Object.entries(profiles ?? {})) { + if ( + profile.ownerPubkey && + normalizePubkey(profile.ownerPubkey) === ownerPubkey + ) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + + return pubkeys; +} + /** * Channel-scoped variant: the managed ∪ relay baseline plus this channel's * bot members (role `bot` or `isAgent`), so member-only agents are included. diff --git a/desktop/src/features/channels/channelSnapshot.test.mjs b/desktop/src/features/channels/channelSnapshot.test.mjs index 37b69f1eb21..5355bfe6f17 100644 --- a/desktop/src/features/channels/channelSnapshot.test.mjs +++ b/desktop/src/features/channels/channelSnapshot.test.mjs @@ -87,6 +87,36 @@ test("remove clears the snapshot for that relay", () => { assert.equal(readChannelSnapshot(RELAY), null); }); +test("cache write evicts disposable entries and retries at quota", () => { + const original = window.localStorage; + const storage = new Map([ + ["buzz-channel-messages.v1:relay:old", "big"], + ["buzz-timeline-skeleton-shape.v1:old", "small"], + ]); + window.localStorage = { + get length() { + return storage.size; + }, + key: (index) => [...storage.keys()][index] ?? null, + getItem: (key) => storage.get(key) ?? null, + setItem(key, value) { + if (!storage.has(key) && storage.size >= 2) { + throw new Error("quota exceeded"); + } + storage.set(key, value); + }, + removeItem: (key) => storage.delete(key), + }; + try { + writeChannelSnapshot(RELAY, [makeChannel()]); + assert.deepEqual(readChannelSnapshot(RELAY), [makeChannel()]); + assert.equal(storage.has("buzz-channel-messages.v1:relay:old"), false); + assert.equal(storage.has("buzz-timeline-skeleton-shape.v1:old"), false); + } finally { + window.localStorage = original; + } +}); + test("write is tolerant of storage failures", () => { const original = window.localStorage.setItem; window.localStorage.setItem = () => { diff --git a/desktop/src/features/channels/channelSnapshot.ts b/desktop/src/features/channels/channelSnapshot.ts index 404377eee3c..265b675790e 100644 --- a/desktop/src/features/channels/channelSnapshot.ts +++ b/desktop/src/features/channels/channelSnapshot.ts @@ -13,6 +13,7 @@ import type { Channel } from "@/shared/api/types"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; const STORAGE_KEY_PREFIX = "buzz-channels.v1"; @@ -51,9 +52,22 @@ export function writeChannelSnapshot( ): void { try { const key = channelSnapshotKey(relayUrl); - const serialized = JSON.stringify({ version: 1, channels }); - if (window.localStorage.getItem(key) === serialized) return; - window.localStorage.setItem(key, serialized); + const previous = window.localStorage.getItem(key); + if (previous) { + try { + const parsed = parseChannelSnapshot(JSON.parse(previous)); + if (parsed && JSON.stringify(parsed) === JSON.stringify(channels)) + return; + } catch { + // Malformed snapshots are replaced below. + } + } + const serialized = JSON.stringify({ + version: 1, + updatedAt: Date.now(), + channels, + }); + setLocalStorageItemWithRecovery(key, serialized); } catch { // Storage access failures are non-fatal. } diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 21ab04f8d6b..d685a961030 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -164,7 +164,7 @@ export function BotActivityComposerAction({ className={cn( "inline-flex items-center justify-center rounded-full border border-border/60 bg-background font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary", isInline - ? "h-7 min-w-0 gap-2 overflow-visible border-transparent bg-transparent px-0 text-xs font-semibold leading-none shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent" + ? "min-w-0 gap-1.5 overflow-visible border-transparent bg-transparent px-0 text-xs font-normal leading-normal shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent" : "h-9 min-w-9 gap-1.5 px-2 text-xs", )} data-testid="bot-activity-composer-trigger" @@ -178,17 +178,16 @@ export function BotActivityComposerAction({ onMouseLeave={closeWithDelay} type="button" > - + {workingAgents.slice(0, 2).map((agent) => ( @@ -201,11 +200,15 @@ export function BotActivityComposerAction({ ) : null} {isInline ? ( - {visibleStatusLabel} + + {visibleStatusLabel} + ) : ( "working" )} diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx new file mode 100644 index 00000000000..216b205fcc6 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx @@ -0,0 +1,66 @@ +import type { ComponentProps } from "react"; + +import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory"; +import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; + +type ChannelComposerActivityAccessoryProps = { + agents: ComponentProps["agents"]; + channel: ComponentProps["channel"]; + currentPubkey: ComponentProps["currentPubkey"]; + onOpenAgentSession: ComponentProps< + typeof BotActivityComposerAction + >["onOpenAgentSession"]; + openAgentSessionPubkey: ComponentProps< + typeof BotActivityComposerAction + >["openAgentSessionPubkey"]; + profiles: ComponentProps["profiles"]; + typingPubkeys: string[]; + visible: boolean; + workingBotPubkeys: string[]; +}; + +export function ChannelComposerActivityAccessory({ + agents, + channel, + currentPubkey, + onOpenAgentSession, + openAgentSessionPubkey, + profiles, + typingPubkeys, + visible, + workingBotPubkeys, +}: ChannelComposerActivityAccessoryProps) { + return ( + +
+ {workingBotPubkeys.length > 0 ? ( +
+ +
+ ) : null} + {typingPubkeys.length > 0 ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index dbce0b6f5ea..92fa172ff5a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -3,6 +3,7 @@ import { Hash, LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; @@ -25,7 +26,6 @@ import { buildVideoReviewContextForMessage, } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; -import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; @@ -40,6 +40,7 @@ import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewMod import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory"; import { containsWelcomePersonaMention, WelcomeComposerBanner, @@ -218,6 +219,7 @@ export const ChannelPane = React.memo(function ChannelPane({ composerWrapperRef, `${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`, "css-variable", + () => messageTimelineRef.current?.settleAtBottom() ?? false, ); const clearWelcomeComposerDismissTimer = React.useCallback(() => { if (welcomeComposerDismissTimerRef.current !== null) { @@ -408,26 +410,18 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; + const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity; const threadComposerBotTypingPubkeys = React.useMemo(() => { - if (!openThreadHeadId) { - return []; - } - - const pubkeys: string[] = []; - for (const entry of botTypingEntries) { - if (entry.threadHeadId !== openThreadHeadId) { - continue; - } - - if ( - !pubkeys.some( - (pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(), - ) - ) { - pubkeys.push(entry.pubkey); - } - } - return pubkeys; + if (!openThreadHeadId) return []; + return botTypingEntries + .filter((entry) => entry.threadHeadId === openThreadHeadId) + .map((entry) => entry.pubkey) + .filter( + (pubkey, index, all) => + all.findIndex( + (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), + ) === index, + ); }, [botTypingEntries, openThreadHeadId]); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; @@ -514,8 +508,6 @@ export const ChannelPane = React.memo(function ChannelPane({ const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const threadViewMode = useThreadViewMode(); - // Focus mode only replaces the wide split thread pane; narrow threads and - // other auxiliary panels keep their existing presentation. const useFocusThreadDrawer = threadViewMode === "focus" && useSplitAuxiliaryPane && @@ -526,6 +518,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ + activeThreadHeadId: threadHeadMessage?.id ?? null, externalScrollTargetId: threadScrollTargetId, onExternalTargetResolved: onThreadScrollTargetResolved, onModeChange: markExitComplete, @@ -734,7 +727,12 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-composer-overlay" ref={composerWrapperRef} > -
+
{timeoutState.active ? (
) : null} + -
-
- {hasComposerBotActivity ? ( -
- -
- ) : null} - {hasTypingActivity ? ( - - ) : null} -
-
+ {/* The activity accessory is anchored in the dock's reserved + bottom rail, so fading it cannot change the observed + overlay height or move the conversation. Its natural + content height remains responsive. */} +
)} {canDropInMainColumn && mainComposerMedia.isDragOver ? ( - + ) : null} ) : null} @@ -881,7 +867,8 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} - onScrollTargetResolved={resolveScrollTarget} + onScrollTargetResolved={() => resolveScrollTarget()} + onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} onUnfollowThread={onUnfollowThread} profiles={profiles} @@ -898,7 +885,8 @@ export const ChannelPane = React.memo(function ChannelPane({ )} threadReplyUnreadCounts={threadReplyUnreadCounts} threadTypingPubkeys={threadTypingPubkeys} - toolbarExtraActions={ + activityAccessoryVisible={hasThreadComposerBotActivity} + activityAccessoryContent={ hasThreadComposerBotActivity ? ( { + assert.deepEqual( + getResolvedThreadTargets({ + externalTargetId: "reply-b", + layoutTargetId: null, + }), + { resolveExternal: true, resolveLayout: false }, + ); + assert.deepEqual( + getResolvedThreadTargets({ + externalTargetId: null, + layoutTargetId: null, + }), + { resolveExternal: true, resolveLayout: false }, + ); +}); + +test("drops a captured layout target when the active thread closes or changes", () => { + const captured = { messageId: "reply-a", threadHeadId: "thread-a" }; + + assert.equal( + getScopedLayoutScrollTargetId({ + activeThreadHeadId: "thread-a", + layoutTarget: captured, + }), + "reply-a", + ); + assert.equal( + getScopedLayoutScrollTargetId({ + activeThreadHeadId: null, + layoutTarget: captured, + }), + null, + ); + const replacementLayoutTargetId = getScopedLayoutScrollTargetId({ + activeThreadHeadId: "thread-b", + layoutTarget: captured, + }); + assert.equal(replacementLayoutTargetId, null); + assert.deepEqual( + getResolvedThreadTargets({ + externalTargetId: "reply-b", + layoutTargetId: replacementLayoutTargetId, + }), + { resolveExternal: true, resolveLayout: false }, + "the stale anchor does not mask the replacement thread target", + ); +}); + test("returns null without a mounted thread body or visible message", () => { assert.equal(findTopVisibleThreadMessageId(null), null); assert.equal( diff --git a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts index 5493eada508..8dd41cfc51e 100644 --- a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts +++ b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts @@ -31,7 +31,25 @@ export function getResolvedThreadTargets({ }; } +type LayoutScrollTarget = { + messageId: string; + threadHeadId: string; +}; + +export function getScopedLayoutScrollTargetId({ + activeThreadHeadId, + layoutTarget, +}: { + activeThreadHeadId: string | null; + layoutTarget: LayoutScrollTarget | null; +}): string | null { + return layoutTarget?.threadHeadId === activeThreadHeadId + ? layoutTarget.messageId + : null; +} + type ThreadViewModeSwitchOptions = { + activeThreadHeadId: string | null; externalScrollTargetId: string | null; onExternalTargetResolved: () => void; onModeChange?: (mode: ThreadViewMode) => void; @@ -39,13 +57,23 @@ type ThreadViewModeSwitchOptions = { /** Preserves the reply being read while the thread changes presentation. */ export function useThreadViewModeSwitch({ + activeThreadHeadId, externalScrollTargetId, onExternalTargetResolved, onModeChange, }: ThreadViewModeSwitchOptions) { - const [layoutScrollTargetId, setLayoutScrollTargetId] = React.useState< - string | null - >(null); + const [layoutScrollTarget, setLayoutScrollTarget] = + React.useState(null); + const layoutScrollTargetId = getScopedLayoutScrollTargetId({ + activeThreadHeadId, + layoutTarget: layoutScrollTarget, + }); + + React.useEffect(() => { + setLayoutScrollTarget((current) => + current?.threadHeadId === activeThreadHeadId ? current : null, + ); + }, [activeThreadHeadId]); const changeThreadViewMode = React.useCallback( (mode: ThreadViewMode, restoreFocus: boolean) => { @@ -54,7 +82,11 @@ export function useThreadViewModeSwitch({ ); const anchorId = findTopVisibleThreadMessageId(body); - setLayoutScrollTargetId(anchorId); + setLayoutScrollTarget( + anchorId && activeThreadHeadId + ? { messageId: anchorId, threadHeadId: activeThreadHeadId } + : null, + ); onModeChange?.(mode); setThreadViewMode(mode); requestAnimationFrame(() => { @@ -69,17 +101,32 @@ export function useThreadViewModeSwitch({ }); }); }, - [onModeChange], + [activeThreadHeadId, onModeChange], ); - const resolveScrollTarget = React.useCallback(() => { - const resolution = getResolvedThreadTargets({ - externalTargetId: externalScrollTargetId, - layoutTargetId: layoutScrollTargetId, - }); - if (resolution.resolveLayout) setLayoutScrollTargetId(null); - if (resolution.resolveExternal) onExternalTargetResolved(); - }, [externalScrollTargetId, layoutScrollTargetId, onExternalTargetResolved]); + const resolveScrollTarget = React.useCallback( + (settledMessageId?: string) => { + const resolution = getResolvedThreadTargets({ + externalTargetId: externalScrollTargetId, + layoutTargetId: layoutScrollTargetId, + }); + if (resolution.resolveExternal) onExternalTargetResolved(); + if (settledMessageId) { + setLayoutScrollTarget((current) => + current?.threadHeadId === activeThreadHeadId && + current.messageId === settledMessageId + ? null + : current, + ); + } + }, + [ + activeThreadHeadId, + externalScrollTargetId, + layoutScrollTargetId, + onExternalTargetResolved, + ], + ); return { changeThreadViewMode, diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index f8db0af5e29..aeb3abb9059 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -88,6 +88,13 @@ export function isChannelUnreadTriggerKind(kind: number, isDmChannel: boolean) { : UNREAD_TRIGGER_KINDS.has(kind); } +export function isHomeActivityEvent( + isDmChannel: boolean, + isThreadedReply: boolean, +) { + return isThreadedReply || isDmChannel; +} + export function withChannelTagFallback( event: RelayEvent, channelId: string, @@ -278,7 +285,7 @@ export function useLiveChannelUpdates( } } else { options.onChannelMessage?.(channelId, event); - if (isThreadedReply) { + if (isHomeActivityEvent(isDmChannel, isThreadedReply)) { options.onThreadReplyNotification?.(channelId, event); } } diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx index 4c94acf102a..76837b65aac 100644 --- a/desktop/src/features/communities/ui/CommunityEditForm.tsx +++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx @@ -68,7 +68,7 @@ export function CommunityEditForm({ let cancelled = false; const timeoutId = window.setTimeout(() => { - void getJoinPolicy(normalizedUrl) + void getJoinPolicy(normalizedUrl, "native") .then((policy) => { if (cancelled || !policy) return; setJoinPolicy(policy); @@ -105,7 +105,7 @@ export function CommunityEditForm({ if (joinPolicyRequired) { try { - const policy = await getJoinPolicy(normalizedUrl); + const policy = await getJoinPolicy(normalizedUrl, "native"); if (!policy) { onSubmit(trimmedName, normalizedUrl); return; diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index 76c0262ea5c..c4e140f7230 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -14,8 +14,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { Input } from "@/shared/ui/input"; import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "1 day", value: 24 * 60 * 60 }, @@ -31,8 +33,9 @@ type CopyStatus = "idle" | "copying" | "copied"; /** * Share-with-link footer for the community invite dialog. * - * Each copy action mints a fresh stateless invite code and places its - * shareable landing-page URL on the clipboard. + * Each copy action mints a fresh database-backed invite code and places its + * shareable landing-page URL on the clipboard. Invites may be unlimited or + * capped to a caller-selected number of successful joins. */ export function InviteLinkSection({ onTtlSecsChange, @@ -42,6 +45,14 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); + const [maxUsesEnabled, setMaxUsesEnabled] = React.useState(true); + const [maxUsesInput, setMaxUsesInput] = React.useState("3"); + const parsedMaxUses = Number(maxUsesInput); + const maxUsesValid = + !maxUsesEnabled || + (Number.isInteger(parsedMaxUses) && + parsedMaxUses >= 1 && + parsedMaxUses <= 10000); const ttlLabel = TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; const copyLabel = @@ -58,10 +69,13 @@ export function InviteLinkSection({ }, [copyStatus]); async function handleCopy() { - if (copyStatus === "copying") return; + if (copyStatus === "copying" || !maxUsesValid) return; setCopyStatus("copying"); try { - const invite = await mintInvite(ttlSecs); + const invite = await mintInvite({ + ttlSecs, + maxUses: maxUsesEnabled ? parsedMaxUses : null, + }); await writeTextToClipboard(invite.url); setCopyStatus("copied"); toast.success("Invite link copied"); @@ -118,13 +132,45 @@ export function InviteLinkSection({ +
+ + + {maxUsesEnabled ? ( + setMaxUsesInput(event.target.value)} + placeholder="3" + type="number" + value={maxUsesInput} + /> + ) : null} + {maxUsesEnabled && !maxUsesValid ? ( + + Enter a whole number from 1 to 10,000 + + ) : null} +
- {showDetailPane && isMessagesMode ? ( + {showDetailPane && detailMode === "messages" ? ( { @@ -931,16 +870,20 @@ export function HomeView({ replies={selectedItemReplies} /> ) : null} - {showDetailPane && isDrafts ? ( - setSelectedDraftKey(null) - : undefined + : isSinglePanelReminderDetailView + ? () => setSelectedReminderId(null) + : undefined } - onDelete={handleDeleteDraft} + onDeleteDraft={handleDeleteDraft} + reminder={selectedReminder} /> ) : null} {profilePanelPubkey ? ( diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 5d6d98323f1..9b6ff57e172 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -1,4 +1,12 @@ -import { ArrowLeft, Hash, Mail, MoreHorizontal, Trash2 } from "lucide-react"; +import { + AlertCircle, + ArrowLeft, + ExternalLink, + LoaderCircle, + Mail, + MoreHorizontal, + Trash2, +} from "lucide-react"; import * as React from "react"; import type { @@ -11,6 +19,7 @@ import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { useCommunities } from "@/features/communities/useCommunities"; import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; +import { hasInboxThreadContext } from "@/features/home/lib/inboxViewHelpers"; import { type InboxDisplayMessage, InboxMessageRow, @@ -60,6 +69,7 @@ type InboxDetailPaneProps = { isDeletingMessage?: boolean; isSendingReply?: boolean; isSinglePanelView?: boolean; + hasThreadContextLoadError?: boolean; isThreadContextLoading?: boolean; item: InboxItem | null; messages?: InboxContextMessage[]; @@ -75,6 +85,7 @@ type InboxDetailPaneProps = { * representative `item.id`. */ selectedEventId: string | null; + unreadBoundaryEventId?: string | null; /** * The default reply-parent event ID derived from the latched anchor's tags * in HomeView (`parentId ?? anchor.id`). Populated once the anchor is found @@ -95,7 +106,7 @@ type InboxDetailPaneProps = { content: string; mediaTags?: string[][]; mentionPubkeys: string[]; - parentEventId: string; + parentEventId: string | null; }) => Promise; onToggleReaction?: ( message: TimelineMessage, @@ -129,6 +140,7 @@ function InboxMessageDetailPane({ isDeletingMessage = false, isSendingReply = false, isSinglePanelView = false, + hasThreadContextLoadError = false, isThreadContextLoading = false, item, messages = [], @@ -138,6 +150,7 @@ function InboxMessageDetailPane({ contextChannelName = null, currentPubkey, selectedEventId, + unreadBoundaryEventId = null, latchedDefaultParentId = null, onBack, onDelete, @@ -161,6 +174,7 @@ function InboxMessageDetailPane({ // scroll centering) key on this. const conversationId = item?.conversationId ?? null; const selectedChannelId = item?.item.channelId ?? null; + const isDirectMessage = item?.item.channelType === "dm"; // Build the plain, non-virtualized timeline the shared hook anchors against. // Live arrivals rerun its layout compensation without changing the target. @@ -372,7 +386,8 @@ function InboxMessageDetailPane({ // (derived from the selected-event anchor at conversation entry), which does // not change when a live incoming message advances the representative item. const composerParentEventId = - replyTarget?.id ?? capturedDefaultParentId ?? item.id; + replyTarget?.id ?? + (isDirectMessage ? null : (capturedDefaultParentId ?? item.id)); const composerReplyTarget = replyTarget && replyTarget.id !== item.id ? { @@ -388,10 +403,27 @@ function InboxMessageDetailPane({ item.item.channelType === "forum" ? item.item.channelType : null; - const contextLabel = channelContextName ?? formatInboxTypeLabel(item); - const hasChannelContext = Boolean(channelContextName); + const isThreadContext = + !isDirectMessage && hasInboxThreadContext(item, messages); + const contextLabel = isThreadContext + ? isDirectMessage + ? `Thread with ${item.senderLabel}` + : channelContextName + ? `Thread in #${channelContextName}` + : "Thread" + : isDirectMessage + ? `DM with ${item.senderLabel}` + : channelContextName + ? `Message in #${channelContextName}` + : formatInboxTypeLabel(item); const contextChannelId = item.item.channelId; - const contextThreadRootId = getThreadReference(item.item.tags).rootId; + const sourceEventId = selectedEventId ?? item.id; + const contextThreadRootId = isThreadContext ? item.conversationId : null; + const openContextLabel = isThreadContext + ? "Open full thread" + : isDirectMessage + ? "Open conversation" + : "Open in channel"; const handleSelectReplyTarget = (message: InboxDisplayMessage) => { setReplyTargetId((currentReplyTargetId) => @@ -430,34 +462,31 @@ function InboxMessageDetailPane({ ) : null}
{canOpenChannel && contextChannelId ? ( - +

+ +

) : (

- {hasChannelContext ? ( - - ) : null} - + {contextLabel}

@@ -468,6 +497,30 @@ function InboxMessageDetailPane({
+ {canOpenChannel && contextChannelId ? ( + + + + + {openContextLabel} + + ) : null} {channel ? (
+ {isThreadContextLoading && displayMessages.length <= 1 ? ( +
+ + Loading surrounding context... +
+ ) : null} + {hasThreadContextLoadError ? ( +
+ + Some message context could not be loaded. +
+ ) : null} {displayMessages.map((message, index) => { - const isAfterSeparator = index === 1; + const hasUnreadBoundary = message.id === unreadBoundaryEventId; + const isAfterSeparator = index === 1 || hasUnreadBoundary; const previousMessage = displayMessages[index - 1]; const isContinuation = !isAfterSeparator && @@ -528,6 +600,7 @@ function InboxMessageDetailPane({ message={message} onSelectReplyTarget={handleSelectReplyTarget} onToggleReaction={onToggleReaction} + showUnreadBoundary={hasUnreadBoundary} /> ); })} @@ -561,17 +634,25 @@ function InboxMessageDetailPane({ />
setReplyTargetId(null) : undefined @@ -586,7 +667,9 @@ function InboxMessageDetailPane({ } placeholder={ canReply - ? `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}` + ? isDirectMessage + ? `Message ${item.senderLabel}` + : `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}` : (disabledReplyReason ?? "Replies are not available for this item.") } diff --git a/desktop/src/features/home/ui/InboxFilterMenu.tsx b/desktop/src/features/home/ui/InboxFilterMenu.tsx new file mode 100644 index 00000000000..6fcc759731b --- /dev/null +++ b/desktop/src/features/home/ui/InboxFilterMenu.tsx @@ -0,0 +1,107 @@ +import { ChevronDown } from "lucide-react"; + +import type { InboxFilter } from "@/features/home/lib/inbox"; +import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +const INBOX_FILTER_OPTIONS: Array<{ + label: string; + value: InboxFilter; +}> = [ + { value: "all", label: "All" }, + { value: "project", label: "Projects" }, + { value: "mention", label: "Mentions" }, + { value: "thread", label: "Threads" }, + { value: "needs_action", label: "Needs action" }, + { value: "agent_activity", label: "Agents" }, + { value: "reminders", label: "Reminders" }, + { value: "drafts", label: "Drafts" }, +]; + +const TRIGGER_CLASS = + "inline-flex h-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted/70 data-[state=open]:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 relative -ml-2 w-auto gap-1 px-2 text-sm font-medium text-foreground"; + +type InboxFilterMenuProps = { + activeDraftCount: number; + dueReminderCount: number; + filter: InboxFilter; + onFilterChange: (value: InboxFilter) => void; + reminderCount: number; +}; + +export function InboxFilterMenu({ + activeDraftCount, + dueReminderCount, + filter, + onFilterChange, + reminderCount, +}: InboxFilterMenuProps) { + const activeFilter = INBOX_FILTER_OPTIONS.find( + (option) => option.value === filter, + ); + const statusLabel = + dueReminderCount > 0 + ? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}` + : activeDraftCount > 0 + ? `${activeDraftCount} active draft${activeDraftCount === 1 ? "" : "s"}` + : null; + + return ( + + + + + + onFilterChange(value as InboxFilter)} + value={filter} + > + {INBOX_FILTER_OPTIONS.map((option) => ( +
+ {option.value === "reminders" ? ( + + ) : null} + + + {option.label} + + {option.value === "reminders" && reminderCount > 0 ? ( + + {reminderCount} + + ) : option.value === "drafts" && activeDraftCount > 0 ? ( + + {activeDraftCount} + + ) : null} + + + +
+ ))} +
+
+
+ ); +} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 603ebc2beb6..20db0b5150d 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,8 +1,9 @@ import { - ChevronDown, + Bell, Clock, Ellipsis, ExternalLink, + FileText, MailOpen, } from "lucide-react"; import * as React from "react"; @@ -13,12 +14,20 @@ import { type InboxItem, type InboxTypeLabel, } from "@/features/home/lib/inbox"; +import { buildInboxListRows } from "@/features/home/lib/inboxListRows"; +import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu"; import { DraftsPanel, + getDraftPreview, type DraftViewItem, } from "@/features/messages/ui/DraftsPanel"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; -import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { isDue } from "@/features/reminders/lib/reminderFilters"; +import { + RemindersPanel, + useReminderSources, +} from "@/features/reminders/ui/RemindersPanel"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -34,13 +43,6 @@ import { MENTION_CHIP_BASE_CLASSES, MESSAGE_MARKDOWN_CLASS, } from "@/shared/ui/mentionChip"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Separator } from "@/shared/ui/separator"; import { Switch } from "@/shared/ui/switch"; @@ -48,24 +50,34 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; -const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ - { value: "all", label: "All" }, - { value: "project", label: "Projects" }, - { value: "mention", label: "Mentions" }, - { value: "thread", label: "Threads" }, - { value: "needs_action", label: "Needs Action" }, - { value: "activity", label: "Activity" }, - { value: "agent_activity", label: "Agents" }, - { value: "reminders", label: "Reminders" }, - { value: "drafts", label: "Drafts" }, -]; +const INBOX_EMPTY_STATE_TITLES: Record = { + all: "No activity yet", + project: "No project work found", + mention: "No mentions found", + thread: "No threads found", + needs_action: "Nothing needs action", + agent_activity: "No agent updates found", + reminders: "No reminders", + drafts: "No drafts", +}; + +const INBOX_UNREAD_EMPTY_STATE_TITLES: Record = { + all: "No unread activity", + project: "No unread project work", + mention: "No unread mentions", + thread: "No unread threads", + needs_action: "No unread items needing action", + agent_activity: "No unread agent updates", + reminders: "No unread reminders", + drafts: "No unread drafts", +}; const INBOX_HEADER_ICON_BUTTON_CLASS = "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted/70 data-[state=open]:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"; const INBOX_PANE_RIGHT_DIVIDER_CLASS = "after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-40 after:w-px after:bg-border/35 after:content-['']"; -function ActivityLabel({ +function InboxLabel({ isDone, isActionRequired, label, @@ -102,6 +114,76 @@ function ActivityLabel({ ); } +function formatReminderStatus(notBefore: number | undefined) { + if (notBefore === undefined) return "Pending"; + const secondsUntil = notBefore - Math.floor(Date.now() / 1_000); + if (secondsUntil <= 0) return "Reminder due"; + if (secondsUntil < 60) return "Reminder in less than a minute"; + if (secondsUntil < 3_600) { + return `Reminder in ${Math.floor(secondsUntil / 60)}m`; + } + if (secondsUntil < 86_400) { + return `Reminder in ${Math.floor(secondsUntil / 3_600)}h`; + } + return `Reminder in ${Math.floor(secondsUntil / 86_400)}d`; +} + +function PersonalItemRow({ + id, + kind, + location, + onClick, + preview, + selected, + status, +}: { + id: string; + kind: "drafts" | "reminders"; + location: InboxTypeLabel | null; + onClick: () => void; + preview: string; + selected: boolean; + status: string; +}) { + const isDraft = kind === "drafts"; + const Icon = isDraft ? FileText : Bell; + + return ( + + ); +} + type InboxListPaneProps = { activeReminderEventIds?: ReadonlySet; agentPubkeys?: ReadonlySet; @@ -118,12 +200,15 @@ type InboxListPaneProps = { onRemindLater: (item: InboxItem) => void; onSelect: (itemId: string) => void; onSelectDraft: (draftKey: string) => void; + onSelectReminder: (reminderId: string) => void; onUnreadOnlyChange: (checked: boolean) => void; selectedConversationId: string | null; selectedDraftKey: string | null; showRightDivider?: boolean; dueReminderCount: number; reminderPubkey?: string; + reminders: readonly Reminder[]; + selectedReminderId: string | null; unreadOnly: boolean; }; @@ -143,24 +228,42 @@ export function InboxListPane({ onRemindLater, onSelect, onSelectDraft, + onSelectReminder, onUnreadOnlyChange, selectedConversationId, selectedDraftKey, showRightDivider = false, dueReminderCount, reminderPubkey, + reminders, + selectedReminderId, unreadOnly, }: InboxListPaneProps) { - const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; - const inboxStatusLabel = - dueReminderCount > 0 - ? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}` - : activeDraftCount > 0 - ? `${activeDraftCount} active draft${activeDraftCount === 1 ? "" : "s"}` - : null; + const isMixedInboxView = filter === "all"; const scrollRef = React.useRef(null); + const inboxRows = React.useMemo( + () => + buildInboxListRows({ + drafts: unreadOnly || !isMixedInboxView ? [] : draftItems, + items, + reminders: unreadOnly + ? [] + : reminders.filter((reminder) => + isDue(reminder, Math.floor(Date.now() / 1_000)), + ), + }), + [draftItems, isMixedInboxView, items, reminders, unreadOnly], + ); + const visibleInboxRows = React.useMemo( + () => + isMixedInboxView + ? inboxRows + : inboxRows.filter((row) => row.kind === "inbox"), + [inboxRows, isMixedInboxView], + ); + const reminderSources = useReminderSources(reminders); const unreadVisibleItemCount = React.useMemo( () => items.reduce((count, item) => count + (doneSet.has(item.id) ? 0 : 1), 0), @@ -174,10 +277,14 @@ export function InboxListPane({ } }, [doneSet, items, onMarkRead]); - const renderItem = (item: InboxItem) => { + const renderItem = (item: InboxItem, dueReminder?: Reminder) => { const isSelected = item.conversationId === selectedConversationId; const isDone = doneSet.has(item.id); - const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false; + const hasActiveReminder = + dueReminder !== undefined || + [item.id, ...item.groupItems.map((groupItem) => groupItem.id)].some( + (eventId) => activeReminderEventIds?.has(eventId) ?? false, + ); const hasChannelTarget = Boolean(item.item.channelId); const typeLabel = getInboxTypeLabel(item); const isSenderAgent = @@ -283,14 +390,28 @@ export function InboxListPane({ className="h-1.5 w-1.5 rounded-full bg-primary" /> ) : null} + {item.unreadCount > 1 ? ( + + {item.unreadCount} unread + + ) : null} {item.timestampLabel}
- + {dueReminder ? ( +
+ + Reminder due +
+ ) : null}
- Show unread + Show unread only
- - - - - - - onFilterChange(value as InboxFilter) - } - value={filter} - > - {FILTER_OPTIONS.map((option) => ( - - - {option.label} - {option.value === "reminders" && - dueReminderCount > 0 ? ( - - {dueReminderCount} - - ) : option.value === "drafts" && - activeDraftCount > 0 ? ( - - {activeDraftCount} - - ) : null} - - - ))} - - - +
@@ -533,7 +596,12 @@ export function InboxListPane({ data-testid="home-inbox-reminders" > {reminderPubkey ? ( - + ) : null}
) : isDrafts ? ( @@ -554,27 +622,89 @@ export function InboxListPane({ data-testid="home-inbox-list" ref={scrollRef} > - {items.length === 0 ? ( + {visibleInboxRows.length > 0 ? ( + row.key} + items={visibleInboxRows} + renderItem={(row) => { + if (row.kind === "inbox") { + return renderItem(row.item, row.dueReminder); + } + + if (row.kind === "reminder") { + const source = reminderSources.get(row.reminder.id); + return ( + { + onSelectReminder(row.reminder.id); + }} + preview={ + row.reminder.content.target?.preview || + row.reminder.content.note || + "Reminder" + } + selected={selectedReminderId === row.reminder.id} + status={formatReminderStatus(row.reminder.notBefore)} + /> + ); + } + + const { entry, source } = row.item; + return ( + { + onSelectDraft(entry.key); + }} + preview={getDraftPreview(entry.draft)} + selected={selectedDraftKey === entry.key} + status="Draft saved" + /> + ); + }} + scrollRef={scrollRef} + /> + ) : (

- {unreadOnly ? "No unread messages" : "No messages found"} + {unreadOnly + ? INBOX_UNREAD_EMPTY_STATE_TITLES[filter] + : INBOX_EMPTY_STATE_TITLES[filter]}

{unreadOnly - ? "Turn off the unread filter to see read messages." - : "Switch back to all mail to see more messages."} + ? "Turn off Show unread only to see read activity." + : filter === "all" + ? "New activity will appear here." + : "Switch back to All to see other activity."}

- ) : ( - item.id} - items={items} - renderItem={renderItem} - scrollRef={scrollRef} - /> )}
)} diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index aaad2bcbd91..04deafb3ffd 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -9,6 +9,7 @@ import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAu import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; +import { UnreadDivider } from "@/features/messages/ui/UnreadDivider"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; @@ -36,6 +37,7 @@ type InboxMessageRowProps = { emoji: string, remove: boolean, ) => Promise; + showUnreadBoundary?: boolean; }; export function InboxMessageRow({ @@ -48,6 +50,7 @@ export function InboxMessageRow({ message, onSelectReplyTarget, onToggleReaction, + showUnreadBoundary = false, }: InboxMessageRowProps) { const timelineMessage = React.useMemo( () => toTimelineMessage(message), @@ -89,6 +92,7 @@ export function InboxMessageRow({ return (
+ {showUnreadBoundary ? : null} {message.isSelected ? (