From e2796d4a8907586b457a65675c2c88c818973173 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 20:42:04 -0600 Subject: [PATCH 01/16] fix(desktop): virtualize channel member lists (#4991) ## Summary - virtualize the unfiltered channel member roster instead of eagerly mounting every member card - retain the existing member search/add flow and archived-member behavior - cover a 500-member roster, bounded mounted rows, and scrolling to the final member in E2E ## Cause The members sidebar rendered every active member card at once. On large channels this mounted hundreds or thousands of avatars, profile/presence consumers, menus, and DOM rows, blocking the renderer even though fetching the roster itself is fast. ## Testing - `pnpm typecheck` - `pnpm exec biome check src/features/channels/ui/MembersSidebar.tsx tests/e2e/channels.spec.ts` - `pnpm build:e2e` - `pnpm exec playwright test tests/e2e/channels.spec.ts --grep 'members sidebar (virtualizes large channel rosters|can invite relay-authorized agents|can invite and remove managed agents|collapses same-persona managed agents)'` (4 passed) - pre-push: `desktop-check`, full `desktop-test` (4,371 passed), branch-skew Implemented by Carl on Wes's behalf. Signed-off-by: Wes Co-authored-by: Carl --- .../features/channels/ui/MembersSidebar.tsx | 17 +++++----- desktop/tests/e2e/channels.spec.ts | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 2659a9c1d4..459e8f7776 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -30,6 +30,7 @@ import { import { formatOwnerLabel } from "@/features/profile/lib/identity"; import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { usePresenceQuery } from "@/features/presence/hooks"; +import { VirtualizedList } from "@/shared/ui/VirtualizedList"; import { useIdentityQuery } from "@/shared/api/hooks"; import { changeChannelMemberRole } from "@/shared/api/tauri"; import type { @@ -199,7 +200,6 @@ export function MembersSidebar({ ), [bots, currentPubkey, people], ); - const allMemberPubkeys = React.useMemo( () => rawMembers.map((member) => member.pubkey), [rawMembers], @@ -217,9 +217,7 @@ export function MembersSidebar({ if (!normalizedSearchQuery) { return activeMembers; } - const profiles = memberProfilesQuery.data?.profiles ?? {}; - return activeMembers.filter((member) => { const normalizedPubkey = normalizePubkey(member.pubkey); const profile = profiles[normalizedPubkey] ?? null; @@ -816,11 +814,14 @@ export function MembersSidebar({ ) : null} ) : filteredActiveMembers.length > 0 ? ( -
- {filteredActiveMembers.map((member) => - renderMemberCard(member, isBot(member)), - )} -
+ member.pubkey} + items={filteredActiveMembers} + renderItem={(member) => + renderMemberCard(member, isBot(member)) + } + /> ) : (

{membersQuery.isLoading diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index ac9d5318e5..d58c3303df 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3394,6 +3394,40 @@ test("home inbox manage affordance opens management without leaving home", async await expect(page).not.toHaveURL(/#\/channels\//); }); +test("members sidebar virtualizes large channel rosters", async ({ page }) => { + await page.goto("/"); + const channelId = await page + .getByTestId("channel-random") + .getAttribute("data-channel-id"); + if (!channelId) { + throw new Error("Random channel id missing."); + } + + const pubkeys = Array.from({ length: 500 }, (_, index) => + (index + 1).toString(16).padStart(64, "0"), + ); + await invokeMockCommand(page, "add_channel_members", { + channelId, + pubkeys, + role: "member", + }); + + await openMembersSidebar(page, "random"); + const memberList = page.getByTestId("members-sidebar-people"); + const memberRows = memberList.locator('[data-testid^="sidebar-member-"]'); + await expect(memberRows.first()).toBeVisible(); + expect(await memberRows.count()).toBeLessThan(50); + + const virtualizedList = memberList.locator(".overflow-y-auto"); + await virtualizedList.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll")); + }); + await expect( + memberList.getByTestId(`sidebar-member-${pubkeys.at(-1)}`), + ).toBeVisible(); +}); + test("members sidebar can invite relay-authorized agents", async ({ page }) => { await installMockBridge(page, { relayAgents: [ From 38bf642fcfa7a9fc1e06d6cf87d66ae94da29341 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 6 Aug 2026 13:13:16 +1000 Subject: [PATCH 02/16] =?UTF-8?q?ci:=20prove=20the=20relay-driven=20mesh?= =?UTF-8?q?=20lifecycle=20=E2=80=94=20discover,=20join,=20infer,=20deny=20?= =?UTF-8?q?=E2=80=94=20with=20real=20nodes=20(#3862)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary CI now proves the full Buzz shared-compute join story end to end: a member can discover another member's served model **through the Buzz relay alone** and run inference over the mesh, while a non-member gets nothing — the relay rejects its auth, and the mesh refuses to route for it even holding a leaked endpoint address. This is deliberately different from mesh-llm's own CI smokes (which bootstrap two nodes with a hand-carried invite token / mdns): here the **relay is the control plane**, exactly like the desktop app: 1. **Membership** — identities A and B are added via `buzz-admin` (kind:13534 NIP-43 roster); C is not. 2. **Advertise** — each member publishes a client-signed kind:30003 discovery note carrying its MeshLLM owner binding and (for the serve node) `serveTargets[].endpointAddr`, covered by an endpoint-binding signature — the exact payload shape the desktop coordinator publishes. 3. **Trust** — the serve node derives its admission allowlist from the relay (statuses ∩ roster) and requires the **exact expected {A, B} owner-id set** before starting with `TrustPolicy::Allowlist`. 4. **Join** — the client verifies owner + endpoint bindings and membership, then dials the relay-discovered endpoint (the desktop join-watcher's `dial_endpoint_addr` step). No out-of-band token. 5. **Infer** — a chat completion against the client's local OpenAI endpoint routes over QUIC to the serve node's model (CPU, SmolLM2-135M, ~105MB). 6. **Deny (differential)** — the stranger's NIP-42 auth must fail with the relay's own membership rejection (`restricted: not a relay member` — successful auth or any unrelated connect error fails the run), and dialing the leaked endpoint must not produce a routed inference — **while the trusted client re-proves inference immediately afterwards**, so a dead serve node can't masquerade as an admission denial. ## What's in the PR - `crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs` — the harness. One process per node (mesh-llm keeps process-global state under `~/.mesh-llm`), orchestrator + serve/client/stranger roles, byte-identical binding payloads to `desktop/src-tauri/src/mesh_llm/identity.rs` (called out with keep-in-sync comments). Child stdout is pumped through a reader thread so every wait has a hard deadline; timed-out children are killed; exit statuses are checked. - `scripts/ci-mesh-lifecycle-smoke.sh` — provisions a membership-gated relay (throwaway owner + signing identities via `buzz-admin generate-key`), runs the harness, cleans up. Fails fast if :3000 is already occupied (a stale open relay would mask gating). - `scripts/start-relay-for-tests.sh` — gains opt-in NIP-43 membership env passthrough (`BUZZ_REQUIRE_RELAY_MEMBERSHIP` + `RELAY_OWNER_PUBKEY` + `BUZZ_RELAY_PRIVATE_KEY`). Default behavior unchanged. - `.github/workflows/mesh-lifecycle.yml` — separate, path-filtered, non-required workflow (mesh paths, the harness's dependency crates, `Cargo.lock`, dispatch), pinned to `ubuntu-24.04`. Caches the mesh native runtime + HF model keyed on the lockfile hash, so a mesh pin bump rolls the runtime cache. Uploads relay + harness logs on failure. ## Scope This is an **independent protocol harness**: it speaks the same wire protocol and payload shapes as the desktop but re-implements the binding/verification logic (the desktop crate is outside the workspace). Regressions inside the desktop's own discovery filtering are the desktop unit tests' job; what this smoke proves is that the relay + mesh-llm SDK + admission stack support the lifecycle end to end. ## Relationship to mesh-llm's CI Follows the shape mesh-llm's own CI proved stable (tiny CPU model, one runner, multiple real mesh-llm processes over real QUIC — cf. their `ci-two-node-client-serving-smoke.sh`), but swaps the token bootstrap for the relay-driven lifecycle, which is the part only Buzz can test. ## Validation Green on GitHub Actions (ubuntu-24.04) across three runs, including after rebases onto the mesh v0.74 upgrade (#3467) and latest main: ``` PASS 1/6: relay-derived allowlist is exactly {A, B} PASS 2/6: serve member ready + advertised model: jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M PASS 3/6: client member discovered + joined via relay PASS 4/6: inference routed over the mesh: "PONG" PASS 5/6: relay rejected the stranger's NIP-42 auth (membership gate) PASS 6/6: stranger denied (gossip visible, inference rejected: 503 all tunnels failed) while trusted inference still routes PASS: full relay-driven mesh lifecycle verified ``` Also validated locally on macOS. `cargo fmt --all --check` and `cargo clippy -p buzz-relay --all-targets -- -D warnings` pass. ## Notes - The harness follows the repo's mesh `[dev-dependencies]` pin automatically, so it doubles as a canary for future mesh upgrades (it already caught the v0.73.1 → v0.74.0 bump during development). - The stranger "deny" accepts either shape mesh-llm exhibits: no model visibility at all, or gossip visibility with inference refused — mesh-llm applies the receiving node's owner policy after the gossip handshake, so admission gates *routing*, not gossip. The differential trusted-inference re-check (PASS 6/6) is what makes that a real denial rather than a dead server. - Model-visibility windows are tunable via `MESH_CLIENT_WINDOW_SECS` / `MESH_STRANGER_WINDOW_SECS` if shared runners prove slow — pin a longer window in the workflow env rather than re-running the job. --------- Signed-off-by: Michael Neale --- .github/workflows/mesh-lifecycle.yml | 111 ++ Cargo.lock | 2 + crates/buzz-relay/Cargo.toml | 5 + .../examples/mesh_relay_lifecycle_smoke.rs | 1065 +++++++++++++++++ scripts/ci-mesh-lifecycle-smoke.sh | 113 ++ scripts/start-relay-for-tests.sh | 15 + 6 files changed, 1311 insertions(+) create mode 100644 .github/workflows/mesh-lifecycle.yml create mode 100644 crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs create mode 100755 scripts/ci-mesh-lifecycle-smoke.sh diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml new file mode 100644 index 0000000000..4780083ba4 --- /dev/null +++ b/.github/workflows/mesh-lifecycle.yml @@ -0,0 +1,111 @@ +name: Mesh Lifecycle +# Relay-driven mesh lifecycle smoke: membership → signed discovery notes → +# relay-derived allowlist → join → CPU inference over QUIC → stranger denied +# (relay membership rejection + no routed inference, with a differential +# trusted-inference health proof so a dead serve node can't fake a denial). +# Runs the full Buzz "shared compute" join story with three real mesh-llm +# node processes on one runner, using the Buzz relay as the control plane +# (no hand-carried invite tokens). Mirrors the shape mesh-llm's own CI uses +# for its two-node smokes (tiny CPU model, one runner, real QUIC mesh). + +on: + push: + branches: [main] + paths: + - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-relay/Cargo.toml' + - 'crates/buzz-admin/**' + - 'crates/buzz-test-client/**' + - 'crates/buzz-ws-client/**' + - 'Cargo.lock' + - 'desktop/src-tauri/src/mesh_llm/**' + - 'scripts/ci-mesh-lifecycle-smoke.sh' + - 'scripts/start-relay-for-tests.sh' + - '.github/workflows/mesh-lifecycle.yml' + pull_request: + paths: + - 'crates/buzz-relay/examples/mesh_*.rs' + - 'crates/buzz-relay/Cargo.toml' + - 'crates/buzz-admin/**' + - 'crates/buzz-test-client/**' + - 'crates/buzz-ws-client/**' + - 'Cargo.lock' + - 'desktop/src-tauri/src/mesh_llm/**' + - 'scripts/ci-mesh-lifecycle-smoke.sh' + - 'scripts/start-relay-for-tests.sh' + - '.github/workflows/mesh-lifecycle.yml' + workflow_dispatch: + +concurrency: + group: mesh-lifecycle-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + lifecycle-smoke: + name: Relay-Driven Mesh Lifecycle Smoke + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + save-if: ${{ github.event_name != 'pull_request' }} + + # The mesh-llm SDK downloads a signed native runtime (llama.cpp CPU + # build) on first init, and the serve node downloads the smoke model + # from HuggingFace on first run. Key on the lockfile so a mesh pin bump + # rolls the runtime cache; the model ref is stable. + - name: Restore mesh runtime + model caches + id: mesh-caches + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cache/mesh-llm/native-runtimes + ~/.cache/huggingface/hub + key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }} + restore-keys: | + mesh-lifecycle-${{ runner.os }}-smollm2-135m- + + - name: Start integration services + run: | + for attempt in 1 2 3; do + if docker compose up -d postgres redis minio minio-init; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "docker compose up failed after 3 attempts" >&2 + exit 1 + fi + echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 + sleep $((attempt * 5)) + done + + - name: Run relay-driven mesh lifecycle smoke + run: ./scripts/ci-mesh-lifecycle-smoke.sh 2>&1 | tee /tmp/mesh-lifecycle-harness.log + + - name: Save mesh runtime + model caches + if: github.ref == 'refs/heads/main' && steps.mesh-caches.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cache/mesh-llm/native-runtimes + ~/.cache/huggingface/hub + key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }} + + - name: Upload relay + harness logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mesh-lifecycle-logs + path: | + /tmp/buzz-relay.log + /tmp/mesh-lifecycle-harness.log + if-no-files-found: ignore diff --git a/Cargo.lock b/Cargo.lock index 937ead564a..73ecb249d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1196,11 +1196,13 @@ dependencies = [ "buzz-relay-mesh", "buzz-sdk", "buzz-search", + "buzz-test-client", "buzz-workflow", "bytes", "chrono", "dashmap", "deadpool-redis", + "ed25519-dalek", "flate2", "futures", "futures-util", diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 41bdc3b9e9..cbad2a3b29 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -86,6 +86,11 @@ dev = ["buzz-auth/dev"] [dev-dependencies] mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +# Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): +# the relay client for discovery notes and the exact ed25519 the mesh owner +# keys use for binding verification. +buzz-test-client = { path = "../buzz-test-client" } +ed25519-dalek = "=3.0.0-rc.0" buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } diff --git a/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs b/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs new file mode 100644 index 0000000000..7544ca09ea --- /dev/null +++ b/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs @@ -0,0 +1,1065 @@ +//! Relay-driven mesh lifecycle smoke — the full Buzz join story, CI-shaped. +//! +//! Unlike `mesh_serve_client_smoke` (Mdns + hand-carried invite token) and +//! `mesh_admission_smoke` (allowlist mechanics, token passed out-of-band), +//! this harness exercises the *relay as the control plane*, the way the +//! desktop app actually joins a mesh: +//! +//! 1. MEMBERSHIP — two Nostr identities are added to a membership-gated +//! buzz-relay (kind:13534 roster via buzz-admin); a third is not. +//! 2. ADVERTISE — each member process publishes a client-signed kind:30003 +//! status note carrying its MeshLLM owner binding +//! (`ownerId`/`ownerVerifyingKey`/`ownerBindingSig`) and, for the serve +//! node, `serveTargets[].endpointAddr` covered by an endpoint binding +//! signature — the exact payload shape the desktop coordinator publishes. +//! 3. TRUST — the serve node derives its admission allowlist from the relay: +//! status notes ∩ membership roster, and requires the *exact* expected +//! owner set before starting with `TrustPolicy::Allowlist`. +//! 4. JOIN — the client node discovers the serve target from the relay, +//! verifies both bindings and membership, and dials the advertised +//! endpoint. No token is ever handed over out-of-band. +//! 5. INFER — a chat completion against the client's local OpenAI endpoint +//! routes over QUIC to the serve node's model. +//! 6. DENY — the stranger's NIP-42 auth must fail with the relay's +//! membership rejection, and even when handed the leaked endpoint +//! address directly it must not complete an inference — *while the +//! trusted client re-verifies inference immediately afterwards*, so a +//! sick serve node cannot masquerade as an admission denial. +//! +//! ## Scope: an independent protocol harness +//! +//! This harness speaks the same wire protocol as the desktop +//! (`desktop/src-tauri/src/mesh_llm/{identity,discovery,coordinator}.rs`) but +//! deliberately re-implements the binding/verification logic rather than +//! linking desktop code (the desktop crate is outside this workspace). The +//! payloads and canonical binding bytes are kept byte-identical — see the +//! keep-in-sync comments below. A regression inside the desktop's own +//! discovery filtering is covered by the desktop unit tests, not this smoke; +//! what this smoke proves is that the relay + mesh-llm SDK + admission stack +//! actually support the lifecycle end to end. +//! +//! One process per node is load-bearing: mesh-llm keeps process-global state +//! (node endpoint key, ownership attestation under `~/.mesh-llm`), so each +//! role runs with an isolated HOME — exactly how the desktop runs it (one +//! machine = one node). +//! +//! Run in CI via `scripts/ci-mesh-lifecycle-smoke.sh` (which provisions the +//! membership-gated relay), or locally: +//! +//! ```text +//! ./scripts/start-relay-for-tests.sh # with membership env set +//! cargo build --profile ci -p buzz-admin +//! BUZZ_ADMIN_BIN=target/ci/buzz-admin \ +//! cargo run --profile ci -p buzz-relay --example mesh_relay_lifecycle_smoke +//! ``` +use std::collections::BTreeSet; +use std::io::{BufRead, Write}; +use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use buzz_test_client::BuzzTestClient; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use mesh_llm_host_runtime::crypto::{load_keystore, save_keystore, OwnerKeypair}; +use mesh_llm_sdk::{client, serve, MeshDiscoveryMode, TrustPolicy}; +use nostr::{Alphabet, Event, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag}; +use sha2::{Digest, Sha256}; + +/// NIP-51 bookmark set reused for client-owned mesh discovery notes +/// (`KIND_BUZZ_MESH_MEMBER_STATUS` in the desktop coordinator). +const KIND_MESH_STATUS: u16 = 30_003; +/// NIP-43 membership roster snapshot. +const KIND_MEMBERSHIP: u16 = 13_534; +const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status"; +const STATUS_K_TAG: &str = "buzz-mesh-status"; + +/// Small, real instruct model; same ref the sibling mesh examples use. +const DEFAULT_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; + +const SERVE_API_PORT: u16 = 19_537; +const SERVE_CONSOLE_PORT: u16 = 13_331; +const CLIENT_API_PORT: u16 = 19_538; +const CLIENT_CONSOLE_PORT: u16 = 13_332; +const STRANGER_API_PORT: u16 = 19_539; +const STRANGER_CONSOLE_PORT: u16 = 13_333; + +/// The trusted client sees the model within seconds on one box; this bounds +/// the stranger's chance to (fail to) see it. Both windows are overridable +/// via env (`MESH_CLIENT_WINDOW_SECS` / `MESH_STRANGER_WINDOW_SECS`) so CI +/// can pin longer windows on slow shared runners instead of re-running the +/// whole job. +const CLIENT_WINDOW_SECS: u64 = 180; +const STRANGER_WINDOW_SECS: u64 = 60; + +fn window_secs(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(default) +} + +fn client_window() -> Duration { + Duration::from_secs(window_secs("MESH_CLIENT_WINDOW_SECS", CLIENT_WINDOW_SECS)) +} + +fn stranger_window() -> Duration { + Duration::from_secs(window_secs( + "MESH_STRANGER_WINDOW_SECS", + STRANGER_WINDOW_SECS, + )) +} + +/// Marker the orchestrator writes to the client child's stdin to request the +/// post-attack inference re-verification. +const VERIFY_AGAIN: &str = "VERIFY_AGAIN"; + +fn main() -> anyhow::Result<()> { + match std::env::var("MESH_ROLE").ok().as_deref() { + Some("serve") => run_role(role_serve()), + Some("client") => run_role(role_client()), + Some("stranger") => run_role(role_stranger()), + _ => orchestrate(), + } +} + +/// Run a role future and exit without unwinding through C++ static +/// destructors: once the native runtime has initialized, normal process exit +/// aborts inside ggml's Metal/CPU device teardown, which would mask the real +/// error under a GGML_ASSERT backtrace. +fn run_role(role: impl std::future::Future>) -> anyhow::Result<()> { + match runtime()?.block_on(role) { + Ok(()) => std::process::exit(0), + Err(error) => { + eprintln!("[role] FAILED: {error:#}"); + std::process::exit(1); + } + } +} + +/// mesh-llm's async chains overflow tokio's default 2 MiB worker stacks; the +/// desktop and the mesh binary itself both run 8 MiB workers for this reason. +fn runtime() -> anyhow::Result { + Ok(tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(8 * 1024 * 1024) + .build()?) +} + +fn env(name: &str) -> anyhow::Result { + std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required for this role")) +} + +fn relay_ws_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +async fn init_native_runtime() -> anyhow::Result<()> { + // The dynamic host runtime installs the recommended signed native runtime + // on first use when none is cached — the same SDK-owned path the desktop + // relies on. CI caches the install dir across runs. + mesh_llm_host_runtime::initialize_host_runtime() + .await + .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error:#}")) +} + +// ── Owner binding payloads ─────────────────────────────────────────────────── +// Byte-for-byte the desktop's `identity::member_binding_bytes` / +// `member_endpoint_binding_bytes`; the client role verifies exactly what the +// desktop coordinator publishes. Keep in sync with +// `desktop/src-tauri/src/mesh_llm/identity.rs`. + +fn member_binding_bytes(member_pubkey: &str) -> Vec { + format!( + "buzz-mesh-owner-binding-v1:{}", + member_pubkey.trim().to_ascii_lowercase() + ) + .into_bytes() +} + +fn member_endpoint_binding_bytes(member_pubkey: &str, endpoint_tokens: &[String]) -> Vec { + let mut endpoints = endpoint_tokens + .iter() + .map(|token| token.trim()) + .filter(|token| !token.is_empty()) + .collect::>(); + endpoints.sort_unstable(); + endpoints.dedup(); + + let mut digest = Sha256::new(); + for endpoint in endpoints { + digest.update((endpoint.len() as u64).to_be_bytes()); + digest.update(endpoint.as_bytes()); + } + format!( + "buzz-mesh-owner-endpoint-binding-v1:{}:{}", + member_pubkey.trim().to_ascii_lowercase(), + hex::encode(digest.finalize()) + ) + .into_bytes() +} + +// ── Relay I/O ──────────────────────────────────────────────────────────────── + +fn status_filter() -> Filter { + Filter::new() + .kind(Kind::Custom(KIND_MESH_STATUS)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::K), STATUS_K_TAG) + .limit(100) +} + +fn membership_filter() -> Filter { + Filter::new().kind(Kind::Custom(KIND_MEMBERSHIP)).limit(1) +} + +async fn query_events( + relay: &mut BuzzTestClient, + filters: Vec, +) -> anyhow::Result> { + let sid = format!("mesh-lifecycle-{}", uuid::Uuid::new_v4().simple()); + relay.subscribe(&sid, filters).await?; + let events = relay + .collect_until_eose(&sid, Duration::from_secs(10)) + .await?; + relay.close_subscription(&sid).await?; + Ok(events) +} + +/// Publish this member's client-signed kind:30003 discovery note — the same +/// payload the desktop coordinator's `bind_payload_to_member` + +/// `build_status_report_event` produce. +async fn publish_status( + relay: &mut BuzzTestClient, + keys: &Keys, + owner: &OwnerKeypair, + serve_targets: &[(String, String)], +) -> anyhow::Result<()> { + let member_pubkey = keys.public_key().to_hex(); + let endpoint_tokens: Vec = serve_targets + .iter() + .map(|(_, endpoint)| endpoint.clone()) + .collect(); + let targets_json: Vec = serve_targets + .iter() + .map(|(model, endpoint)| serde_json::json!({ "modelId": model, "endpointAddr": endpoint })) + .collect(); + let models_json: Vec = serve_targets + .iter() + .map(|(model, _)| serde_json::json!({ "id": model })) + .collect(); + let payload = serde_json::json!({ + "ownerId": owner.owner_id(), + "ownerVerifyingKey": hex::encode(owner.verifying_key().as_bytes()), + "ownerBindingSig": + hex::encode(owner.sign_bytes(&member_binding_bytes(&member_pubkey))), + "ownerEndpointBindingSig": hex::encode(owner.sign_bytes( + &member_endpoint_binding_bytes(&member_pubkey, &endpoint_tokens), + )), + "serveTargets": targets_json, + "models": models_json, + }); + let d_tag = format!("{STATUS_D_TAG_PREFIX}:{}", owner.owner_id()); + let d = Tag::parse(["d", d_tag.as_str()]).map_err(|error| anyhow::anyhow!("{error}"))?; + let k = Tag::parse(["k", STATUS_K_TAG]).map_err(|error| anyhow::anyhow!("{error}"))?; + let event = EventBuilder::new(Kind::Custom(KIND_MESH_STATUS), payload.to_string()) + .tags([d, k]) + .sign_with_keys(keys)?; + let ok = relay.send_event(event).await?; + anyhow::ensure!( + ok.accepted, + "relay rejected mesh status note: {}", + ok.message + ); + Ok(()) +} + +// ── Discovery verification (mirrors desktop `discovery.rs`) ───────────────── + +fn membership_set(events: &[Event]) -> Option> { + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MEMBERSHIP) + .max_by_key(|event| event.created_at) + .map(|event| { + event + .tags + .iter() + .filter_map(|tag| { + let slice = tag.as_slice(); + let name = slice.first()?; + if name != "member" && name != "p" { + return None; + } + slice + .get(1) + .map(|pubkey| pubkey.trim().to_ascii_lowercase()) + }) + .filter(|pubkey| !pubkey.is_empty()) + .collect() + }) +} + +/// `ownerId` must equal sha256(ownerVerifyingKey) and `ownerBindingSig` must +/// verify against the note's Nostr author — a stored note cannot be re-pointed +/// at someone else's mesh identity. +fn verified_owner_id(event: &Event) -> Option { + let content = serde_json::from_str::(&event.content).ok()?; + let owner_id = content.get("ownerId")?.as_str()?.trim(); + let verifying_key_bytes: [u8; 32] = + hex::decode(content.get("ownerVerifyingKey")?.as_str()?.trim()) + .ok()? + .try_into() + .ok()?; + if owner_id != hex::encode(Sha256::digest(verifying_key_bytes)) { + return None; + } + let signature_bytes = hex::decode(content.get("ownerBindingSig")?.as_str()?.trim()).ok()?; + let signature = Signature::from_slice(&signature_bytes).ok()?; + let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).ok()?; + verifying_key + .verify(&member_binding_bytes(&event.pubkey.to_hex()), &signature) + .ok()?; + Some(owner_id.to_string()) +} + +/// Extract `(model_id, endpoint_addr)` pairs from a status note, but only when +/// the endpoint binding signature covers exactly the advertised tokens. +fn verified_serve_targets(event: &Event) -> Vec<(String, String)> { + let Ok(content) = serde_json::from_str::(&event.content) else { + return Vec::new(); + }; + let targets: Vec<(String, String)> = content + .get("serveTargets") + .and_then(serde_json::Value::as_array) + .map(|targets| { + targets + .iter() + .filter_map(|target| { + let model = target.get("modelId")?.as_str()?.trim().to_string(); + let endpoint = target.get("endpointAddr")?.as_str()?.trim().to_string(); + (!endpoint.is_empty()).then_some((model, endpoint)) + }) + .collect() + }) + .unwrap_or_default(); + if targets.is_empty() { + return Vec::new(); + } + let endpoint_tokens: Vec = targets + .iter() + .map(|(_, endpoint)| endpoint.clone()) + .collect(); + let Some(verifying_key) = content + .get("ownerVerifyingKey") + .and_then(serde_json::Value::as_str) + .and_then(|value| hex::decode(value.trim()).ok()) + .and_then(|value| <[u8; 32]>::try_from(value).ok()) + .and_then(|value| VerifyingKey::from_bytes(&value).ok()) + else { + return Vec::new(); + }; + let Some(signature) = content + .get("ownerEndpointBindingSig") + .and_then(serde_json::Value::as_str) + .and_then(|value| hex::decode(value.trim()).ok()) + .and_then(|value| Signature::from_slice(&value).ok()) + else { + return Vec::new(); + }; + let bytes = member_endpoint_binding_bytes(&event.pubkey.to_hex(), &endpoint_tokens); + if verifying_key.verify(&bytes, &signature).is_err() { + return Vec::new(); + } + targets +} + +/// Owner ids of current members with valid owner bindings — the relay-derived +/// admission roster (`owner_ids_from_events` semantics). +fn member_owner_ids(events: &[Event]) -> BTreeSet { + let Some(members) = membership_set(events) else { + return BTreeSet::new(); + }; + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .filter(|event| members.contains(&event.pubkey.to_hex().to_ascii_lowercase())) + .filter_map(verified_owner_id) + .collect() +} + +// ── Roles ──────────────────────────────────────────────────────────────────── + +/// SERVE (member A): publish presence, derive the allowlist from the relay, +/// require the exact expected owner set, start an allowlist serve node, +/// publish the endpoint, park. +async fn role_serve() -> anyhow::Result<()> { + init_native_runtime().await?; + let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let owner = load_keystore(std::path::Path::new(&env("MESH_OWNER_KEY")?), None) + .map_err(|error| anyhow::anyhow!("loading serve owner keystore: {error}"))?; + // The exact owner ids the orchestrator provisioned for members A and B. + // Waiting for this exact set (not a count) means the allowlist can only + // ever contain the intended identities. + let expected_owners: BTreeSet = env("MESH_EXPECTED_OWNERS")? + .split(',') + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect(); + anyhow::ensure!( + expected_owners.contains(&owner.owner_id()), + "serve owner id is not in MESH_EXPECTED_OWNERS" + ); + + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("serve member relay connect: {error}"))?; + publish_status(&mut relay, &keys, &owner, &[]).await?; + println!("STATUS_PUBLISHED"); + + // TRUST: wait until every expected member owner is visible via the relay + // (statuses ∩ roster), then admit exactly those owners. + let deadline = Instant::now() + Duration::from_secs(120); + loop { + let events = query_events(&mut relay, vec![status_filter(), membership_filter()]).await?; + let mut visible = member_owner_ids(&events); + visible.insert(owner.owner_id()); + if visible.is_superset(&expected_owners) { + break; + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for expected owners {expected_owners:?}; saw {visible:?}" + ); + tokio::time::sleep(Duration::from_secs(2)).await; + } + let allowlist: Vec = expected_owners.iter().cloned().collect(); + println!("ALLOWLIST:{}", allowlist.join(",")); + // The upcoming serve::start() blocks through a possibly multi-minute model + // download; an idle relay socket gets closed under it. Reconnect after. + let _ = relay.disconnect().await; + + let cfg = serve::EmbeddedServeConfig::builder() + .model(&model) + .api_port(SERVE_API_PORT) + .console_port(SERVE_CONSOLE_PORT) + // Desktop no-leak invariants: never publish mesh presence, never + // auto-discover. The Buzz relay is the only discovery surface. + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(600)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(allowlist) + .build(); + let node = serve::start(cfg).await?; + let endpoint = node + .invite_token() + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("serve node produced no endpoint address"))?; + println!("ENDPOINT:{endpoint}"); + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + let served = wait_for_model(&http, &base, Duration::from_secs(600)) + .await? + .ok_or_else(|| anyhow::anyhow!("serve node never loaded the model"))?; + + // ADVERTISE: refresh the status note with the live serve target, exactly + // what the desktop's 45s heartbeat publishes once serving. Fresh relay + // connection — the pre-download socket has long been idle-closed. + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("serve member relay reconnect: {error}"))?; + publish_status( + &mut relay, + &keys, + &owner, + &[(served.clone(), endpoint.clone())], + ) + .await?; + println!("READY:{served}"); + + // Park; the orchestrator kills this process when the run is over. + loop { + tokio::time::sleep(Duration::from_secs(3600)).await; + } +} + +/// CLIENT (member B): publish presence, discover + verify the serve target +/// from the relay, dial it, prove inference routes over the mesh — then wait +/// for the orchestrator's `VERIFY_AGAIN` and re-prove inference after the +/// stranger's admission attack, so denial is differential, not absence. +async fn role_client() -> anyhow::Result<()> { + init_native_runtime().await?; + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let owner = load_keystore(std::path::Path::new(&env("MESH_OWNER_KEY")?), None) + .map_err(|error| anyhow::anyhow!("loading client owner keystore: {error}"))?; + + let mut relay = BuzzTestClient::connect(&relay_ws_url(), &keys) + .await + .map_err(|error| anyhow::anyhow!("client member relay connect: {error}"))?; + publish_status(&mut relay, &keys, &owner, &[]).await?; + println!("STATUS_PUBLISHED"); + + // JOIN: poll the relay until a *verified* serve target from another member + // appears — membership roster, owner binding, and endpoint binding all + // checked, mirroring `availability_from_events`. + let deadline = Instant::now() + Duration::from_secs(900); + let (endpoint, allowlist) = loop { + let events = query_events(&mut relay, vec![status_filter(), membership_filter()]).await?; + let members = membership_set(&events).unwrap_or_default(); + let target = events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .filter(|event| members.contains(&event.pubkey.to_hex().to_ascii_lowercase())) + .filter(|event| verified_owner_id(event).is_some_and(|id| id != owner.owner_id())) + .flat_map(verified_serve_targets) + .next(); + if let Some((_, endpoint)) = target { + let owners: Vec = member_owner_ids(&events).into_iter().collect(); + break (endpoint, owners); + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for a verified serve target on the relay" + ); + tokio::time::sleep(Duration::from_secs(3)).await; + }; + println!("TARGET_FOUND"); + + let cfg = client::EmbeddedClientConfig::builder() + .api_port(CLIENT_API_PORT) + .console_port(CLIENT_CONSOLE_PORT) + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(180)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(allowlist) + .build(); + let node = client::start(cfg).await?; + // The relay-discovered endpoint is the dial target — the same + // `dial_endpoint_addr` step the desktop's join watcher performs. The + // desktop's watcher retries every 15s (a first QUIC dial can time out + // while the serve node's endpoint is still warming up). mesh-llm itself + // retries internally per attempt, so keep the outer budget small. + let mut dial_result = Ok(()); + for attempt in 1..=3u32 { + dial_result = node.join_token(&endpoint).await; + match &dial_result { + Ok(()) => break, + Err(error) => { + eprintln!("[client] dial attempt {attempt}/3 failed: {error:#}"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + dial_result?; + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + let Some(model) = wait_for_model(&http, &base, client_window()).await? else { + println!("NONE"); + let _ = node.stop().await; + std::process::exit(0); + }; + println!("SEEN:{model}"); + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_OK:{content}"), + Err(error) => { + println!("INFER_FAIL:{error}"); + let _ = node.stop().await; + std::process::exit(0); + } + } + + // Post-attack health proof: hold the mesh session open until the + // orchestrator has run the stranger, then prove the serve node still + // routes trusted inference. This is what makes the stranger's failure an + // admission denial rather than a dead server. + let line = tokio::task::spawn_blocking(|| { + let mut line = String::new(); + std::io::stdin().read_line(&mut line).map(|_| line) + }) + .await??; + if line.trim() == VERIFY_AGAIN { + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_AGAIN_OK:{content}"), + Err(error) => println!("INFER_AGAIN_FAIL:{error}"), + } + } + let _ = node.stop().await; + // Skip C++ static destructors (ggml aborts in global teardown). + std::process::exit(0); +} + +/// STRANGER (non-member C): NIP-42 auth must fail with the relay's membership +/// rejection, and the mesh must not route inference for it even with the +/// leaked endpoint address. +async fn role_stranger() -> anyhow::Result<()> { + let keys = Keys::parse(&env("BUZZ_MEMBER_NSEC")?)?; + let leaked_endpoint = env("MESH_LEAKED_ENDPOINT")?; + + // DENY (relay read): the membership-gated relay must reject the + // stranger's NIP-42 auth with its membership error specifically. Any + // other failure (relay down, timeout) is inconclusive and fails the + // test; a successful auth is a gating regression and also fails. + match BuzzTestClient::connect(&relay_ws_url(), &keys).await { + Err(error) => { + let message = error.to_string(); + if message.contains("not a relay member") { + println!("RELAY_DENIED_MEMBERSHIP"); + } else { + println!("RELAY_ERR:{message}"); + } + } + Ok(mut relay) => { + let statuses = query_events(&mut relay, vec![status_filter()]) + .await + .map(|events| { + events + .iter() + .filter(|event| event.kind.as_u16() == KIND_MESH_STATUS) + .count() + }) + .unwrap_or(usize::MAX); + println!("RELAY_AUTH_OK:{statuses}"); + let _ = relay.disconnect().await; + } + } + + // DENY (admission): dial the serve node directly with the leaked endpoint. + // The stranger's owner id is not on the allowlist, so the mesh must refuse + // to route anything to it. Note the dial itself may locally "succeed" — + // mesh-llm applies the receiving node's owner policy after the handshake — + // so the decisive probe is routed inference, cross-checked against the + // trusted client's post-attack inference by the orchestrator. + init_native_runtime().await?; + let cfg = client::EmbeddedClientConfig::builder() + .api_port(STRANGER_API_PORT) + .console_port(STRANGER_CONSOLE_PORT) + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Nostr) + .console_ui(true) + .startup_timeout(Duration::from_secs(180)) + .owner_key(env("MESH_OWNER_KEY")?) + .owner_required(true) + .build(); + let node = client::start(cfg).await?; + let _ = node.join_token(&leaked_endpoint).await; + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + match wait_for_model(&http, &base, stranger_window()).await? { + Some(model) => { + println!("SEEN:{model}"); + match try_completion(&http, &base, &model).await { + Ok(content) => println!("INFER_OK:{content}"), + Err(error) => println!("INFER_FAIL:{error}"), + } + } + None => println!("NONE"), + } + let _ = node.stop().await; + std::process::exit(0); +} + +// ── Orchestrator ───────────────────────────────────────────────────────────── + +fn orchestrate() -> anyhow::Result<()> { + let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + eprintln!("[lifecycle] model: {model}"); + let admin = + std::env::var("BUZZ_ADMIN_BIN").unwrap_or_else(|_| "target/ci/buzz-admin".to_string()); + anyhow::ensure!( + std::path::Path::new(&admin).exists(), + "buzz-admin binary not found at {admin} (set BUZZ_ADMIN_BIN)" + ); + + let scratch = std::env::temp_dir().join(format!("buzz-mesh-lifecycle-{}", std::process::id())); + std::fs::create_dir_all(&scratch)?; + + // Nostr identities: A (serve member), B (client member), C (stranger). + let member_a = Keys::generate(); + let member_b = Keys::generate(); + let stranger = Keys::generate(); + + // MeshLLM owner keystores, one per role. The orchestrator keeps the owner + // ids so the serve role can gate on the exact expected identity set. + let make_owner = |name: &str| -> anyhow::Result<(String, String)> { + let keypair = OwnerKeypair::generate(); + let path = scratch.join(format!("{name}.keystore.json")); + save_keystore(&path, &keypair, None, true) + .map_err(|error| anyhow::anyhow!("saving {name} keystore: {error}"))?; + Ok((path.display().to_string(), keypair.owner_id())) + }; + let (serve_key, serve_owner_id) = make_owner("serve")?; + let (client_key, client_owner_id) = make_owner("client")?; + let (stranger_key, _stranger_owner_id) = make_owner("stranger")?; + let expected_owners = format!("{serve_owner_id},{client_owner_id}"); + + // MEMBERSHIP: A and B become relay members via buzz-admin (publishes the + // kind:13534 roster snapshot). C is deliberately not added. + for (label, keys) in [("A", &member_a), ("B", &member_b)] { + let status = Command::new(&admin) + .args(["add-member", "--pubkey", &keys.public_key().to_hex()]) + .status()?; + anyhow::ensure!(status.success(), "buzz-admin add-member {label} failed"); + eprintln!( + "[lifecycle] member {label} added: {}", + keys.public_key().to_hex() + ); + } + + // Isolated HOMEs (mesh-llm keeps node identity under ~/.mesh-llm), with + // the native runtime + HF caches resolved from the real environment first. + let native_cache = std::env::var_os("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or(real_cache_dir()?.join("mesh-llm/native-runtimes")); + let hf_cache = std::env::var_os("HF_HUB_CACHE") + .map(std::path::PathBuf::from) + .unwrap_or(real_cache_dir()?.join("huggingface/hub")); + let role_home = |name: &str| -> anyhow::Result { + let home = scratch.join(format!("{name}-home")); + std::fs::create_dir_all(&home)?; + Ok(home.display().to_string()) + }; + + let exe = std::env::current_exe()?; + let secret_hex = |keys: &Keys| format!("{}", keys.secret_key().display_secret()); + + // SERVE child (member A). + eprintln!("[lifecycle] starting SERVE member (relay-derived allowlist)..."); + let mut serve_child = Command::new(&exe) + .env("MESH_ROLE", "serve") + .env("MESH_SMOKE_MODEL", &model) + .env("BUZZ_MEMBER_NSEC", secret_hex(&member_a)) + .env("MESH_OWNER_KEY", &serve_key) + .env("MESH_EXPECTED_OWNERS", &expected_owners) + .env("HOME", role_home("serve")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let serve_lines = spawn_line_reader( + serve_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no serve stdout"))?, + ); + let serve_guard = KillOnDrop(&mut serve_child); + expect_line(&serve_lines, "STATUS_PUBLISHED", Duration::from_secs(180))?; + eprintln!("[lifecycle] serve member published its discovery note"); + + // CLIENT child (member B) — started now so the serve node can see B's + // owner binding on the relay and admit it. stdin stays piped for the + // post-attack VERIFY_AGAIN request. + eprintln!("[lifecycle] starting CLIENT member (relay-driven join)..."); + let mut client_child = Command::new(&exe) + .env("MESH_ROLE", "client") + .env("BUZZ_MEMBER_NSEC", secret_hex(&member_b)) + .env("MESH_OWNER_KEY", &client_key) + .env("HOME", role_home("client")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let client_lines = spawn_line_reader( + client_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no client stdout"))?, + ); + let mut client_stdin = client_child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("no client stdin"))?; + let client_guard = KillOnDrop(&mut client_child); + + let allowlist = expect_line(&serve_lines, "ALLOWLIST:", Duration::from_secs(300))?; + anyhow::ensure!( + allowlist.split(',').map(str::trim).collect::>() + == BTreeSet::from([serve_owner_id.as_str(), client_owner_id.as_str()]), + "LIFECYCLE FAIL: serve allowlist {allowlist} is not exactly the expected member owners" + ); + eprintln!("[lifecycle] PASS 1/6: relay-derived allowlist is exactly {{A, B}}: {allowlist}"); + let endpoint = expect_line(&serve_lines, "ENDPOINT:", Duration::from_secs(600))?; + eprintln!("[lifecycle] serve endpoint acquired (relay advertisement lands with READY)"); + let served = expect_line(&serve_lines, "READY:", Duration::from_secs(900))?; + eprintln!("[lifecycle] PASS 2/6: serve member ready + advertised model: {served}"); + + // Client verdict: discovery + join + first inference. + let (which, seen) = expect_one_of(&client_lines, &["SEEN:", "NONE"], Duration::from_secs(900))?; + anyhow::ensure!( + which == "SEEN:", + "LIFECYCLE FAIL: client member never saw the model via relay-driven join" + ); + eprintln!("[lifecycle] PASS 3/6: client member discovered + joined via relay, sees: {seen}"); + let (which, detail) = expect_one_of( + &client_lines, + &["INFER_OK:", "INFER_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + which == "INFER_OK:", + "LIFECYCLE FAIL: client saw the model but inference did not route: {detail}" + ); + eprintln!("[lifecycle] PASS 4/6: inference routed over the mesh: {detail:?}"); + + // STRANGER child (C): must be denied by the relay's membership gate and + // must not route inference through the mesh. + eprintln!("[lifecycle] starting STRANGER (non-member, leaked endpoint)..."); + let mut stranger_child = Command::new(&exe) + .env("MESH_ROLE", "stranger") + .env("BUZZ_MEMBER_NSEC", secret_hex(&stranger)) + .env("MESH_OWNER_KEY", &stranger_key) + .env("MESH_LEAKED_ENDPOINT", &endpoint) + .env("HOME", role_home("stranger")?) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) + .env("HF_HUB_CACHE", &hf_cache) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let stranger_lines = spawn_line_reader( + stranger_child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no stranger stdout"))?, + ); + let stranger_guard = KillOnDrop(&mut stranger_child); + + // Relay leg: only the relay's own membership rejection counts as denied. + let (which, detail) = expect_one_of( + &stranger_lines, + &["RELAY_DENIED_MEMBERSHIP", "RELAY_AUTH_OK:", "RELAY_ERR:"], + Duration::from_secs(120), + )?; + match which { + "RELAY_DENIED_MEMBERSHIP" => { + eprintln!("[lifecycle] PASS 5/6: relay rejected the stranger's NIP-42 auth (membership gate)"); + } + "RELAY_AUTH_OK:" => anyhow::bail!( + "LIFECYCLE FAIL: membership-gated relay authenticated a non-member (saw {detail} statuses)" + ), + _ => anyhow::bail!( + "LIFECYCLE INCONCLUSIVE: stranger relay connect failed for a non-membership reason: {detail}" + ), + } + + // Mesh leg: the stranger must not complete an inference. + let (which, detail) = expect_one_of( + &stranger_lines, + &["SEEN:", "NONE"], + stranger_window() + Duration::from_secs(300), + )?; + let stranger_infer = if which == "SEEN:" { + let model = detail; + let (verdict, body) = expect_one_of( + &stranger_lines, + &["INFER_OK:", "INFER_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + verdict != "INFER_OK:", + "LIFECYCLE FAIL: stranger reused the leaked endpoint and inferred through {model}: {body:?}" + ); + format!("saw gossip for {model} but inference was rejected: {body}") + } else { + "saw no routed model".to_string() + }; + // Defuse the kill-guard (the stranger exits on its own after its verdict); + // dropping it here would SIGKILL the child before we can read its status. + std::mem::forget(stranger_guard); + let stranger_status = wait_child(&mut stranger_child, Duration::from_secs(60), "stranger")?; + anyhow::ensure!( + stranger_status.success(), + "LIFECYCLE INCONCLUSIVE: stranger child exited with {stranger_status}" + ); + + // Differential health proof: the trusted client must still route + // inference *after* the stranger's attempt. Without this, a serve node + // that died mid-run would make the stranger's failure look like a denial. + client_stdin.write_all(format!("{VERIFY_AGAIN}\n").as_bytes())?; + client_stdin.flush()?; + let (which, detail) = expect_one_of( + &client_lines, + &["INFER_AGAIN_OK:", "INFER_AGAIN_FAIL:"], + Duration::from_secs(180), + )?; + anyhow::ensure!( + which == "INFER_AGAIN_OK:", + "LIFECYCLE FAIL: trusted client could not infer after the stranger's attempt \ + (serve node unhealthy — stranger denial is inconclusive): {detail}" + ); + eprintln!( + "[lifecycle] PASS 6/6: stranger denied ({stranger_infer}) while trusted inference \ + still routes: {detail:?}" + ); + + eprintln!("[lifecycle] PASS: full relay-driven mesh lifecycle verified"); + drop(client_guard); + let _ = wait_child(&mut client_child, Duration::from_secs(60), "client"); + drop(serve_guard); + let _ = serve_child.wait(); + let _ = std::fs::remove_dir_all(&scratch); + Ok(()) +} + +// ── Child-process plumbing ─────────────────────────────────────────────────── + +/// Lines from a child's stdout, pumped by a dedicated reader thread so waits +/// can enforce hard deadlines (`BufRead::lines` alone blocks indefinitely). +struct ChildLines { + rx: mpsc::Receiver>, +} + +fn spawn_line_reader(stdout: ChildStdout) -> ChildLines { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in std::io::BufReader::new(stdout).lines() { + if tx.send(line).is_err() { + break; + } + } + }); + ChildLines { rx } +} + +/// Wait (with a hard deadline) for a line starting with `prefix`; returns the +/// suffix. Non-matching lines are skipped. +fn expect_line(lines: &ChildLines, prefix: &str, timeout: Duration) -> anyhow::Result { + expect_one_of(lines, &[prefix], timeout).map(|(_, rest)| rest) +} + +/// Wait (with a hard deadline) for a line starting with any of `prefixes`; +/// returns the matched prefix and the suffix. +fn expect_one_of<'a>( + lines: &ChildLines, + prefixes: &[&'a str], + timeout: Duration, +) -> anyhow::Result<(&'a str, String)> { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| anyhow::anyhow!("timed out waiting for one of {prefixes:?}"))?; + match lines.rx.recv_timeout(remaining) { + Ok(Ok(line)) => { + for prefix in prefixes { + if let Some(rest) = line.strip_prefix(prefix) { + return Ok((prefix, rest.to_string())); + } + } + } + Ok(Err(error)) => { + anyhow::bail!("child stdout read error before {prefixes:?}: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => { + anyhow::bail!("timed out waiting for one of {prefixes:?}") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("child exited before printing one of {prefixes:?}") + } + } + } +} + +/// Wait for a child to exit, killing it if the deadline passes. +fn wait_child(child: &mut Child, timeout: Duration, label: &str) -> anyhow::Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("{label} child exceeded {timeout:?} and was killed"); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +/// Kill the child on drop so a failed assertion never leaks a process. +struct KillOnDrop<'a>(&'a mut Child); +impl Drop for KillOnDrop<'_> { + fn drop(&mut self) { + let _ = self.0.kill(); + } +} + +/// The real user's OS cache dir, resolved before HOME is overridden for the +/// child processes. +fn real_cache_dir() -> anyhow::Result { + let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME is not set"))?; + #[cfg(target_os = "macos")] + return Ok(std::path::PathBuf::from(home).join("Library/Caches")); + #[cfg(not(target_os = "macos"))] + return Ok(std::path::PathBuf::from(home).join(".cache")); +} + +/// Poll `/models` until a model id appears or the window closes. +async fn wait_for_model( + http: &reqwest::Client, + api_base: &str, + window: Duration, +) -> anyhow::Result> { + let url = format!("{api_base}/models"); + let deadline = Instant::now() + window; + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_secs(3)).await; + if let Ok(resp) = http.get(&url).send().await { + let body = resp.text().await.unwrap_or_default(); + if let Ok(json) = serde_json::from_str::(&body) { + if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) { + return Ok(Some(id.to_string())); + } + } + } + } + Ok(None) +} + +/// One chat completion against a node's OpenAI endpoint; Ok(content) only if +/// it really routed and produced non-empty output. +async fn try_completion( + http: &reqwest::Client, + api_base: &str, + model: &str, +) -> anyhow::Result { + let resp = http + .post(format!("{api_base}/chat/completions")) + .timeout(Duration::from_secs(120)) + .json(&serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}], + "max_tokens": 16, + "temperature": 0.0 + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + if !status.is_success() { + anyhow::bail!("{status}: {body}"); + } + let content = serde_json::from_str::(&body)?["choices"][0]["message"] + ["content"] + .as_str() + .unwrap_or("") + .to_string(); + if content.trim().is_empty() { + anyhow::bail!("empty content"); + } + Ok(content) +} diff --git a/scripts/ci-mesh-lifecycle-smoke.sh b/scripts/ci-mesh-lifecycle-smoke.sh new file mode 100755 index 0000000000..887e2b15a6 --- /dev/null +++ b/scripts/ci-mesh-lifecycle-smoke.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# ============================================================================= +# ci-mesh-lifecycle-smoke.sh — relay-driven mesh lifecycle smoke +# ============================================================================= +# Provisions a membership-gated buzz-relay and runs the full relay-driven +# mesh lifecycle harness (crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs): +# membership → signed discovery notes → relay-derived allowlist → join → +# inference over QUIC → stranger denied. +# +# Mirrors the shape of mesh-llm's own CI smoke scripts (single runner, tiny +# CPU model, real multi-process mesh), but with the Buzz relay as the control +# plane instead of a hand-carried invite token. +# +# Usage: +# ./scripts/ci-mesh-lifecycle-smoke.sh [--profile ] [--no-build] +# +# Env: +# MESH_SMOKE_MODEL Override the served model ref (default: SmolLM2-135M). +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +CARGO_PROFILE="${CARGO_PROFILE:-ci}" +SKIP_BUILD=false +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) CARGO_PROFILE="$2"; shift 2 ;; + --no-build) SKIP_BUILD=true; shift ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +BLUE='\033[0;34m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' +log() { echo -e "${BLUE}[mesh-lifecycle]${NC} $*"; } +ok() { echo -e "${GREEN}[mesh-lifecycle]${NC} $*"; } +err() { echo -e "${RED}[mesh-lifecycle]${NC} $*" >&2; } + +# ── Build ───────────────────────────────────────────────────────────────────── + +if [[ "${SKIP_BUILD}" == "true" ]]; then + log "Skipping build (--no-build)" +else + log "Building relay, admin CLI, and lifecycle harness (profile: ${CARGO_PROFILE})..." + cargo build --profile "${CARGO_PROFILE}" -p buzz-relay -p buzz-admin -p git-credential-nostr + cargo build --profile "${CARGO_PROFILE}" -p buzz-relay --example mesh_relay_lifecycle_smoke +fi + +ADMIN_BIN="target/${CARGO_PROFILE}/buzz-admin" +HARNESS_BIN="target/${CARGO_PROFILE}/examples/mesh_relay_lifecycle_smoke" +for bin in "${ADMIN_BIN}" "${HARNESS_BIN}" "target/${CARGO_PROFILE}/buzz-relay"; do + if [[ ! -x "${bin}" ]]; then + err "Missing binary: ${bin}" + exit 1 + fi +done + +# ── Relay identity + membership gating ─────────────────────────────────────── +# The relay owner and signing key are throwaway CI identities. RELAY_OWNER +# never participates in the mesh; it only satisfies the NIP-43 requirement +# that a membership-gated relay has an administrable owner. + +log "Generating relay owner + signing identities..." +read_keys() { "${ADMIN_BIN}" generate-key 2>/dev/null; } +OWNER_OUT="$(read_keys)" +RELAY_OWNER_PUBKEY="$(echo "${OWNER_OUT}" | awk '/Public key:/ {print $3}')" +SIGNER_OUT="$(read_keys)" +BUZZ_RELAY_PRIVATE_KEY="$(echo "${SIGNER_OUT}" | awk '/Secret key:/ {print $3}')" +if [[ -z "${RELAY_OWNER_PUBKEY}" || -z "${BUZZ_RELAY_PRIVATE_KEY}" ]]; then + err "Failed to generate relay identities via buzz-admin generate-key" + exit 1 +fi +export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true +export RELAY_OWNER_PUBKEY +export BUZZ_RELAY_PRIVATE_KEY + +# ── Start the membership-gated relay ───────────────────────────────────────── +# A stale relay on :3000 would pass the readiness poll while silently running +# WITHOUT membership gating — the stranger-denied assertion would then fail +# (or worse, an open relay would mask a real gating regression). Fail fast. +if lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then + err "Port 3000 is already in use — stop the existing relay first (its config would not be membership-gated)" + exit 1 +fi + +log "Starting membership-gated relay..." +CARGO_PROFILE="${CARGO_PROFILE}" ./scripts/start-relay-for-tests.sh --no-build + +cleanup() { + log "Stopping relay..." + if [[ -f /tmp/buzz-relay.pid ]]; then + kill "$(cat /tmp/buzz-relay.pid)" 2>/dev/null || true + fi + # The harness kills its own children; sweep any stragglers from a hard fail. + pkill -f mesh_relay_lifecycle_smoke 2>/dev/null || true +} +trap cleanup EXIT + +# ── Run the lifecycle harness ──────────────────────────────────────────────── + +log "Running relay-driven mesh lifecycle smoke..." +RELAY_URL=ws://localhost:3000 \ +DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ +REDIS_URL=redis://localhost:6379 \ +BUZZ_ADMIN_BIN="${ADMIN_BIN}" \ + "${HARNESS_BIN}" + +ok "Relay-driven mesh lifecycle smoke passed" diff --git a/scripts/start-relay-for-tests.sh b/scripts/start-relay-for-tests.sh index 85aa101356..b9d93935c0 100755 --- a/scripts/start-relay-for-tests.sh +++ b/scripts/start-relay-for-tests.sh @@ -151,6 +151,20 @@ fi # ── Start relay ────────────────────────────────────────────────────────────── log "Starting relay..." + +# Optional NIP-43 membership gating: exported by callers that need a +# membership-gated relay (e.g. the mesh lifecycle smoke). All three must be +# set together — the relay fails fast otherwise. +MEMBERSHIP_ENV=() +if [[ "${BUZZ_REQUIRE_RELAY_MEMBERSHIP:-}" == "true" ]]; then + MEMBERSHIP_ENV+=( + BUZZ_REQUIRE_RELAY_MEMBERSHIP=true + RELAY_OWNER_PUBKEY="${RELAY_OWNER_PUBKEY:?RELAY_OWNER_PUBKEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" + BUZZ_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:?BUZZ_RELAY_PRIVATE_KEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" + ) + log "Membership gating enabled (NIP-43)" +fi + nohup env \ DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ REDIS_URL=redis://localhost:6379 \ @@ -159,6 +173,7 @@ nohup env \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ + ${MEMBERSHIP_ENV[@]+"${MEMBERSHIP_ENV[@]}"} \ "./target/${CARGO_PROFILE}/buzz-relay" > /tmp/buzz-relay.log 2>&1 & echo $! > /tmp/buzz-relay.pid From 96ae141763e5459beb68a847e0082e931af72f4c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 22:53:25 -0600 Subject: [PATCH 03/16] fix(desktop): skip native notifications outside app bundles (#5004) ## Summary - require the macOS process to be running from an actual `.app` bundle before initializing `UNUserNotificationCenter` - keep the existing bundle-identifier requirement - cover packaged, case-insensitive `.app`, raw `target/debug`, and extensionless paths ## Why PR #4799 guarded native notification initialization with `NSBundle.mainBundle.bundleIdentifier != nil`. Tauri embeds a bundle identifier in raw development executables, so `tauri dev` passed that guard and `UNUserNotificationCenter.current()` raised an uncaught `NSInternalInconsistencyException` because LaunchServices had no bundle proxy. ## Validation - focused macOS notification tests: 6 passed - direct raw debug executable no longer raises the notification-center exception - pre-commit formatting hook passed - pre-push package checks passed on pushed commit `f29a6664d2a863e7b8aa527f6149fd00b183e4de` The first push attempt hit an unrelated timing-test failure in `relay_admission::tests::concurrent_429_extends_the_window_for_parked_waiters`; its focused rerun passed, and the complete pre-push package suite passed on the next push. Signed-off-by: Wes Co-authored-by: Carl --- desktop/src-tauri/src/macos_notifications.rs | 54 ++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index da2312b457..5bcedd8975 100644 --- a/desktop/src-tauri/src/macos_notifications.rs +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -8,6 +8,7 @@ use std::{ collections::VecDeque, + path::Path, ptr::NonNull, sync::{mpsc, Mutex, OnceLock}, time::Duration, @@ -128,7 +129,7 @@ pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { // objc2 cannot turn that exception into a Rust error, so do not call // into the framework at all in this environment. eprintln!( - "buzz-desktop: macOS notifications disabled because the process has no bundle identifier" + "buzz-desktop: macOS notifications disabled because the process is not running from an app bundle" ); return Ok(()); } @@ -293,7 +294,30 @@ pub(crate) fn take_pending_activations() -> Result, Strin } fn is_bundled_application() -> bool { - NSBundle::mainBundle().bundleIdentifier().is_some() + let bundle = NSBundle::mainBundle(); + bundle.bundleIdentifier().is_some() + && bundle.executablePath().is_some_and(|executable_path| { + is_application_bundle_layout( + Path::new(&bundle.bundlePath().to_string()), + Path::new(&executable_path.to_string()), + ) + }) +} + +fn is_application_bundle_layout(bundle_path: &Path, executable_path: &Path) -> bool { + let Some(macos_path) = executable_path.parent() else { + return false; + }; + let Some(contents_path) = macos_path.parent() else { + return false; + }; + + bundle_path + .extension() + .is_some_and(|extension| extension == "app") + && macos_path.file_name() == Some("MacOS".as_ref()) + && contents_path.file_name() == Some("Contents".as_ref()) + && contents_path.parent() == Some(bundle_path) } fn target_from_response(response: &UNNotificationResponse) -> Option { @@ -311,10 +335,12 @@ fn parse_target(serialized: &str) -> Option { #[cfg(test)] mod tests { use super::{ - is_bundled_application, parse_target, permission_state, queue_activation, - take_pending_activations, NotificationPermissionState, MAX_PENDING_ACTIVATIONS, + is_application_bundle_layout, is_bundled_application, parse_target, permission_state, + queue_activation, take_pending_activations, NotificationPermissionState, + MAX_PENDING_ACTIVATIONS, }; use objc2_user_notifications::UNAuthorizationStatus; + use std::path::Path; #[test] fn activation_queue_is_bounded_and_drained() { @@ -336,6 +362,26 @@ mod tests { assert!(!is_bundled_application()); } + #[test] + fn requires_the_executable_to_use_the_app_bundle_layout() { + assert!(is_application_bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Buzz.app/Contents/MacOS/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/tmp/Fake.app"), + Path::new("/tmp/Fake.app/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug"), + Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Other.app/Contents/MacOS/buzz-desktop"), + )); + } + #[test] fn maps_native_authorization_states_to_frontend_contract() { assert_eq!( From 19b41e9c8edafb02159e597935a8c41f1b6493a2 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 6 Aug 2026 08:39:17 -0600 Subject: [PATCH 04/16] fix(desktop): stop rate-limited reconnect backfill from tearing down the authenticated socket (#4990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Users on v0.5.5 report "Can't reach the relay" toggling with brief "connected" flashes (field reports; also the macOS confirmation in #4908). #4737 closed the stuck-reconnect gaps; this is the opposite failure: the client redials fine, but then kills its own healthy socket. Mechanism (all on `main`): 1. AUTH succeeds → session emits `connected` (`relayClientSession.ts:583`), then awaits `replayLiveSubscriptions()`. 2. Paged channel backfill issues history REQs (`relayReconnectReplay.ts`, page limit 500). 3. A `CLOSED rate-limited:` on a **history** REQ arms the rate-limit gate but still rejects the history promise (`relayClosedRecovery.ts:38-51`). 4. The rejection escapes `replayLiveSubscriptions()` → `resetConnection()` tears down the authenticated socket. 5. Reconnect → AUTH OK → replay rate-limited again → loop. Each iteration re-spends the rate-limit budget, so the loop is self-sustaining. ## Fix Contain backfill failures inside the replay. Each subscription's paged backfill now retries behind the rate-limit gate up to `PAGE_REPLAY_MAX_ATTEMPTS` (3), then degrades to live-only **for this connection**. Socket health no longer depends on backfill success. Nothing is lost: the replay cursor (`lastSeenCreatedAt`) only advances on delivered events, so the next reconnect replays the same missed window. ## Red/green proof - Commit 1 (Pinky): e2e injecting `CLOSED rate-limited:` into the mid-replay history REQ — **red on main** (expected 1 reconnect dial, observed 2; connected-flash then teardown). - Commit 2 (this fix): same test **green unchanged** — one dial, state stays `connected` through the rate-limit hint plus the next backoff window. Why existing coverage missed it: the prior rate-limit e2e pre-armed the gate *before* replay (replay politely waits), and the CLOSED-injection test targeted a *live* subscription (which has its own retry path). Nobody injected back-pressure from the history REQ itself. ## Verification - `pnpm test`: 4374/4374 pass. - `playwright test tests/e2e/relay-reconnect.spec.ts`: 14/14 pass, including the new spec. - `tsc --noEmit` clean; Biome clean on touched files (pre-existing warnings on main in `personaCatalogRelay.test.mjs` / `terminal.css` untouched). ## Not addressed here (follow-ups from the same field reports) - AUTH terminal latch is too aggressive for relay-internal `error:` rejections (3 strikes during a relay bad window → stuck until click/relaunch; #4908). - Server-side: `relay.drainJitterMs` (#4542) defaults to 0 — enabling it on the hosted relay removes the deploy thundering herd that triggers these rate-limit storms. --------- Signed-off-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz> Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> --- desktop/src/shared/api/relayClientShared.ts | 11 + .../shared/api/relayReconnectReplay.test.mjs | 250 ++++++++++++++++++ .../src/shared/api/relayReconnectReplay.ts | 101 ++++++- desktop/src/testing/e2eBridge.ts | 14 + desktop/tests/e2e/relay-reconnect.spec.ts | 68 +++++ 5 files changed, 431 insertions(+), 13 deletions(-) diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 9108f7b6d7..3952b6af6f 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -60,6 +60,17 @@ type LiveSubscription = { onEvent: (event: RelayEvent) => void; resolveReady?: () => void; lastSeenCreatedAt?: number; + /** + * Lower bound of a reconnect backfill window that has not yet completed. + * + * Events on the restored live REQ advance `lastSeenCreatedAt` regardless of + * backfill success, so after an exhausted backfill the cursor alone would + * make the next reconnect skip the unresolved older window — silent message + * loss. This floor is pinned when paging starts and cleared only when a + * backfill pass completes; the next replay starts from + * `min(pendingReplaySince, cursor window)`. + */ + pendingReplaySince?: number; closedRetryAttempt?: number; closedRetryTimeout?: number; }; diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 59253a459f..c175272885 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -3,11 +3,13 @@ import test from "node:test"; import { buildReconnectReplayFilter, + PAGE_REPLAY_MAX_ATTEMPTS, replayLiveSubscriptions, REPLAY_BATCH_SIZE, shouldPageReconnectReplay, } from "./relayReconnectReplay.ts"; import { buildChannelFilter } from "./relayChannelFilters.ts"; +import { prepareSubscriptionEvent } from "./relayClosedRecovery.ts"; // ── Fake-timer + Date.now setup for gate tests ──────────────────────────────── @@ -600,6 +602,254 @@ test("batch-1 arms gate mid-replay: batch-2 is withheld until gate expires", asy ); }); +// ── Backfill failure containment ───────────────────────────────────────────── + +test("history backfill rejection never escapes replayLiveSubscriptions", async () => { + resetGate(0); + const filter = buildChannelFilter("channel-1", 50); + const subscriptions = new Map([ + [ + "live-1", + { + mode: "live", + filter, + onEvent: () => {}, + lastSeenCreatedAt: 1000, + }, + ], + ]); + + let historyCalls = 0; + // Must resolve — a rejection here is the socket-killing flap regression. + await replayLiveSubscriptions({ + subscriptions, + now: 2000, + sendRaw: async () => {}, + requestHistory: async () => { + historyCalls++; + throw new Error("rate-limited: quota exceeded; retry in 4s"); + }, + }); + + assert.equal( + historyCalls, + PAGE_REPLAY_MAX_ATTEMPTS, + "backfill must retry a bounded number of times, then degrade", + ); +}); + +test("backfill retry waits out the armed gate, then succeeds", async () => { + resetGate(0); + const delivered = []; + const filter = buildChannelFilter("channel-1", 50); + const subscriptions = new Map([ + [ + "live-1", + { + mode: "live", + filter, + onEvent: (event) => delivered.push(event), + lastSeenCreatedAt: 1000, + }, + ], + ]); + + const attemptAtMs = []; + let armGate; + const gateArmed = new Promise((resolve) => { + armGate = resolve; + }); + const replayPromise = replayLiveSubscriptions({ + subscriptions, + now: 2000, + sendRaw: async () => {}, + requestHistory: async () => { + attemptAtMs.push(fakeNow); + if (attemptAtMs.length === 1) { + // Mirror relayClosedRecovery: the CLOSED handler arms the gate + // before rejecting the history promise. + activateRateLimit(4); + armGate(); + throw new Error("rate-limited: quota exceeded; retry in 4s"); + } + return [event("recovered", 1500)]; + }, + }); + + // Wait until the gate is actually armed, then expire it. The retry loop is + // (or will be) suspended in waitForRateLimit; expiring the gate releases it. + await gateArmed; + tickTo(4_001); + await replayPromise; + + assert.equal(attemptAtMs.length, 2, "one failure, one retry"); + assert.ok( + attemptAtMs[1] >= 4_001, + "retry must not fire before the rate-limit gate expires", + ); + assert.deepEqual( + delivered.map((e) => e.id), + ["recovered"], + "the retried backfill must deliver its events", + ); +}); + +test("backfill retry aborts when the subscription was replaced", async () => { + resetGate(0); + const filter = buildChannelFilter("channel-1", 50); + const subscription = { + mode: "live", + filter, + onEvent: () => {}, + lastSeenCreatedAt: 1000, + }; + const subscriptions = new Map([["live-1", subscription]]); + + let historyCalls = 0; + await replayLiveSubscriptions({ + subscriptions, + now: 2000, + sendRaw: async () => {}, + requestHistory: async () => { + historyCalls++; + // Simulate the subscription being torn down while the REQ is in flight. + subscriptions.delete("live-1"); + throw new Error("rate-limited: quota exceeded; retry in 4s"); + }, + }); + + assert.equal( + historyCalls, + 1, + "no retry may target a subscription that no longer exists", + ); +}); + +test("exhausted backfill pins the floor: next replay still requests the original window after live events advance the cursor", async () => { + // The blocking review scenario on PR #4990: cursor=1000, all backfill + // attempts fail, a live event at 2100 then advances lastSeenCreatedAt via + // prepareSubscriptionEvent. Without the pinned floor, the next reconnect + // would start near 2095 and silently skip 1001..1999. + resetGate(0); + const filter = buildChannelFilter("channel-1", 50); + const subscription = { + mode: "live", + filter, + onEvent: () => {}, + lastSeenCreatedAt: 1000, + }; + const subscriptions = new Map([["live-1", subscription]]); + + // Reconnect 1: every backfill attempt is rate-limited. + await replayLiveSubscriptions({ + subscriptions, + now: 2000, + sendRaw: async () => {}, + requestHistory: async () => { + throw new Error("rate-limited: quota exceeded; retry in 4s"); + }, + }); + assert.equal( + subscription.pendingReplaySince, + 995, + "exhausted backfill must pin the unresolved window's lower bound", + ); + + // A live event arrives through the normal cursor path. + prepareSubscriptionEvent(subscription, event("live-newer", 2100)); + assert.equal(subscription.lastSeenCreatedAt, 2100); + + // Reconnect 2: backfill now succeeds. It must request the ORIGINAL window. + const historyFilters = []; + await replayLiveSubscriptions({ + subscriptions, + now: 2200, + sendRaw: async () => {}, + requestHistory: async (filter) => { + historyFilters.push(filter); + return []; + }, + }); + + assert.equal(historyFilters.length, 1); + assert.equal( + historyFilters[0].since, + 995, + "replay must start from the pinned floor, not the advanced cursor", + ); + assert.equal( + subscription.pendingReplaySince, + undefined, + "a completed backfill must clear the pinned floor", + ); + + // Reconnect 3: with the floor cleared, replay returns to the cursor. + const laterFilters = []; + await replayLiveSubscriptions({ + subscriptions, + now: 2300, + sendRaw: async () => {}, + requestHistory: async (filter) => { + laterFilters.push(filter); + return []; + }, + }); + assert.equal( + laterFilters[0].since, + 2095, + "after recovery the cursor governs again", + ); +}); + +test("in-flight stale abort keeps the pinned floor for the superseding connection", async () => { + // Race from re-review of b70a6716d/c493d378b: production supersession bumps + // the connection GENERATION while the same subscription key and object + // survive in the map. The identity guard alone stays true, so only the + // combined guard (outer isActive && identity) aborts the stale pass. That + // abort must NOT count as completion — the pinned floor belongs to the + // superseding connection's replay. + resetGate(0); + const filter = buildChannelFilter("channel-1", 50); + const subscription = { + mode: "live", + filter, + onEvent: () => {}, + lastSeenCreatedAt: 1000, + }; + const subscriptions = new Map([["live-1", subscription]]); + + let generationActive = true; + let historyCalls = 0; + await replayLiveSubscriptions({ + subscriptions, + now: 2000, + sendRaw: async () => {}, + isActive: () => generationActive, + requestHistory: async () => { + historyCalls++; + // Connection A is superseded while the REQ is in flight: the generation + // advances, but the subscription keeps its key AND object identity — + // exactly what production supersession does. + generationActive = false; + // A full page would otherwise continue paging — the post-await + // combined guard must abort instead. + return eventRange("full", 1001, 500); + }, + }); + + assert.equal(historyCalls, 1, "stale generation must stop paging"); + assert.equal( + subscriptions.get("live-1"), + subscription, + "precondition: key and object survive supersession untouched", + ); + assert.equal( + subscription.pendingReplaySince, + 995, + "a stale-generation abort must not clear the floor the new connection needs", + ); +}); + // ── Teardown ────────────────────────────────────────────────────────────────── test("teardown — restore Date.now", () => { diff --git a/desktop/src/shared/api/relayReconnectReplay.ts b/desktop/src/shared/api/relayReconnectReplay.ts index 74b752f666..cb962ce846 100644 --- a/desktop/src/shared/api/relayReconnectReplay.ts +++ b/desktop/src/shared/api/relayReconnectReplay.ts @@ -13,6 +13,22 @@ const RECONNECT_REPLAY_SKEW_SECS = 5; export const RECONNECT_REPLAY_PAGE_LIMIT = 500; export const RECONNECT_REPLAY_PAGE_CONCURRENCY = 4; +/** + * Maximum attempts for one subscription's paged history backfill. + * + * Backfill failures must never escape `replayLiveSubscriptions`: by the time + * paging starts, every live REQ has already been re-established on a healthy, + * authenticated socket. Letting a history rejection propagate makes the + * session tear that socket down (`resetConnection`) and reconnect straight + * into the same rate-limit window — the "briefly connected → can't reach the + * relay" flap loop. Instead each sub retries behind the rate-limit gate a + * bounded number of times, then degrades to live-only for this connection. + * The window's lower bound is pinned in `pendingReplaySince` while unresolved + * (live events advance `lastSeenCreatedAt` regardless of backfill success), + * so the next reconnect still requests the missed window. + */ +export const PAGE_REPLAY_MAX_ATTEMPTS = 3; + /** * Maximum live subscriptions sent per relay REQ burst during reconnect. * @@ -78,6 +94,16 @@ export function shouldPageReconnectReplay(filter: RelaySubscriptionFilter) { ); } +/** + * Page one subscription's missed-window history. + * + * Returns `true` only when the window was genuinely completed (short page or + * boundary reached). Returns `false` when the pass aborted because the + * connection went stale (`isActive()` false) — callers must NOT treat that as + * completion: the same subscription object is shared with the superseding + * connection, and clearing its pinned `pendingReplaySince` on a stale abort + * would erase the floor the new connection still needs. + */ export async function replayReconnectHistoryPages({ subscription, since, @@ -90,11 +116,11 @@ export async function replayReconnectHistoryPages({ until: number; isActive: () => boolean; requestHistory: (filter: RelaySubscriptionFilter) => Promise; -}) { +}): Promise { let pageUntil = until; while (pageUntil >= since) { - if (!isActive()) return; + if (!isActive()) return false; const events = await requestHistory( buildReconnectReplayFilter( @@ -105,17 +131,18 @@ export async function replayReconnectHistoryPages({ ), ); - if (!isActive()) return; + if (!isActive()) return false; for (const event of events) subscription.onEvent(event); - if (events.length < RECONNECT_REPLAY_PAGE_LIMIT) return; + if (events.length < RECONNECT_REPLAY_PAGE_LIMIT) return true; const oldestCreatedAt = events[0]?.created_at; - if (oldestCreatedAt === undefined || oldestCreatedAt <= since) return; + if (oldestCreatedAt === undefined || oldestCreatedAt <= since) return true; pageUntil = oldestCreatedAt < pageUntil ? oldestCreatedAt : oldestCreatedAt - 1; } + return true; } export async function replayLiveSubscriptions({ @@ -167,13 +194,21 @@ export async function replayLiveSubscriptions({ entry[1].mode === "live", ) .map(([subId, subscription]) => { - const replaySince = + const cursorSince = subscription.lastSeenCreatedAt === undefined ? undefined : Math.max( 0, subscription.lastSeenCreatedAt - RECONNECT_REPLAY_SKEW_SECS, ); + // A pinned floor from a previously failed backfill takes precedence + // over the cursor: live events kept advancing `lastSeenCreatedAt` + // while the older window stayed unresolved, and starting from the + // cursor would skip it permanently. + const replaySince = + cursorSince === undefined + ? subscription.pendingReplaySince + : Math.min(cursorSince, subscription.pendingReplaySince ?? Infinity); const shouldPageReplay = replaySince !== undefined && shouldPageReconnectReplay(subscription.filter); @@ -237,13 +272,53 @@ export async function replayLiveSubscriptions({ ), pageReplayConcurrency, async ({ subId, subscription, replaySince }) => { - await replayReconnectHistoryPages({ - subscription, - since: replaySince, - until: now, - isActive: () => subscriptions.get(subId) === subscription, - requestHistory, - }); + // Backfill is best-effort: a failure here (typically a `rate-limited:` + // CLOSED on a history REQ) must never escape to the session and tear + // down the healthy, authenticated socket carrying the live REQs — that + // is the connect→drop flap loop. Retry behind the gate a bounded number + // of times, then degrade to live-only for this connection. + // + // Pin the window's lower bound before the first attempt: events on the + // already-restored live REQ advance `lastSeenCreatedAt` independently + // of backfill success, so without the pin an exhausted backfill + // followed by one live event would make the next reconnect skip the + // unresolved window permanently. Cleared only on a completed pass. + subscription.pendingReplaySince = replaySince; + for (let attempt = 1; attempt <= PAGE_REPLAY_MAX_ATTEMPTS; attempt++) { + try { + const completed = await replayReconnectHistoryPages({ + subscription, + since: replaySince, + until: now, + // Both guards are required. The identity check catches the sub + // being torn down/replaced; the outer isActive() catches + // connection supersession, which bumps the generation while the + // SAME subscription key and object survive in the map — identity + // alone stays true and a stale pass could complete and clear the + // floor the superseding connection needs. + isActive: () => + isActive() && subscriptions.get(subId) === subscription, + requestHistory, + }); + // A stale-connection abort is NOT completion: the superseding + // connection shares this subscription object and still needs the + // pinned floor for its own replay. Only a genuinely completed + // window may release it. + if (completed) subscription.pendingReplaySince = undefined; + return; + } catch (error) { + console.warn( + `[reconnect replay] history backfill attempt ${attempt}/${PAGE_REPLAY_MAX_ATTEMPTS} failed for ${subId}:`, + error, + ); + if (attempt === PAGE_REPLAY_MAX_ATTEMPTS) return; + // The failed REQ's CLOSED handler arms the rate-limit gate before + // rejecting; wait for it (no-op when the failure wasn't back-pressure) + // and re-check that this replay's connection is still current. + if (isRateLimited()) await waitForRateLimit(); + if (subscriptions.get(subId) !== subscription || !isActive()) return; + } + } }, ); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 54238323ae..1987e00ff1 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1211,6 +1211,8 @@ declare global { ) => void; /** Inject CLOSED into every active mock live subscription. */ __BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__?: (reason: string) => number; + /** Queue CLOSED responses for channel history REQs. */ + __BUZZ_E2E_QUEUE_CHANNEL_HISTORY_CLOSES__?: (reasons: string[]) => void; __BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void; __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; __BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number; @@ -2967,6 +2969,7 @@ const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); const mockAuthResponses: Array<{ success: boolean; message: string }> = []; +const mockChannelHistoryCloses: string[] = []; let mockWebsocketUnavailable = false; const relayWebsocketConnectAttemptStarts: number[] = []; let mockWebsocketSendMutexWedged = false; @@ -9691,6 +9694,13 @@ function sendToMockSocket(args: { } const channelId = filter["#h"]?.[0]; + if (channelId && subId.startsWith("history-")) { + const closeReason = mockChannelHistoryCloses.shift(); + if (closeReason) { + sendWsText(socket.handler, ["CLOSED", subId, closeReason]); + return; + } + } if (!channelId) { // Aux-backfill filters (reactions/deletions) are `#e`-keyed with no // channel tag — serve them across all channel stores like the relay. @@ -9945,6 +9955,7 @@ export function maybeInstallE2eTauriMocks() { mockClosedChannelLiveSubscription = false; mockWebsocketUnavailable = false; mockAuthResponses.length = 0; + mockChannelHistoryCloses.length = 0; relayWebsocketConnectAttemptStarts.length = 0; deferredSendMessageLiveEchoes.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig @@ -10188,6 +10199,9 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__ = (responses) => { mockAuthResponses.push(...responses); }; + window.__BUZZ_E2E_QUEUE_CHANNEL_HISTORY_CLOSES__ = (reasons) => { + mockChannelHistoryCloses.push(...reasons); + }; window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__ = (reason) => { let closed = 0; for (const socket of mockSockets.values()) { diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index ec3e87171d..289d8ef318 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -134,6 +134,19 @@ async function closeLiveSubscriptions( expect(closed).toBeGreaterThan(0); } +async function queueChannelHistoryCloses( + page: import("@playwright/test").Page, + reasons: string[], +) { + await page.evaluate((queued) => { + const queue = window.__BUZZ_E2E_QUEUE_CHANNEL_HISTORY_CLOSES__; + if (!queue) { + throw new Error("E2E channel history CLOSED seam is not installed."); + } + queue(queued); + }, reasons); +} + async function driveConnectionDegraded( page: import("@playwright/test").Page, state: "connected" | "reconnecting" | "stalled" | "disconnected", @@ -253,6 +266,61 @@ test("authenticated reconnect reports connected while replay is rate-limited", a await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0); }); +test("rate-limited reconnect backfill does not tear down the authenticated socket", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Give the channel live subscription a replay cursor. On reconnect this + // causes a paged channel-history REQ in addition to restoring the live REQ. + await emitMockMessages(page, [ + { + content: `replay cursor ${Date.now()}`, + createdAt: Math.floor(Date.now() / 1_000), + }, + ]); + const attemptsBeforeReconnect = (await getMockWebsocketConnectAttempts(page)) + .length; + + // Inject back-pressure from the history REQ itself. This differs from a + // pre-armed gate and from CLOSED on the live subscription: AUTH has already + // succeeded and the socket is healthy when replay backfill is rejected. + await queueChannelHistoryCloses(page, [ + "rate-limited: quota exceeded; retry in 1s", + ]); + await disconnectMockWebsockets(page); + + await expect + .poll( + async () => + (await getMockWebsocketConnectAttempts(page)).length - + attemptsBeforeReconnect, + { timeout: 3_000 }, + ) + .toBe(1); + await expect + .poll(() => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + ) + .toBe("connected"); + + // Hold through the rate-limit hint plus the next base-backoff window. The + // authenticated socket must remain the only reconnect attempt, rather than + // flashing connected and redialing after replay rejects. + await page.waitForTimeout(2_500); + expect( + (await getMockWebsocketConnectAttempts(page)).length - + attemptsBeforeReconnect, + ).toBe(1); + expect( + await page.evaluate(() => + window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(), + ), + ).toBe("connected"); +}); + test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"], From 5babb97ca3b9c9d640f7dcdfdef456178e868ff7 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 6 Aug 2026 08:39:36 -0600 Subject: [PATCH 05/16] feat(desktop): show selected community in rail (#5000) ## Summary - add a persistent vertical pill beside the active community - keep the selected state visually distinct from unread dots and mention badges - preserve the existing `aria-current` selection semantics ## Screenshot ![Selected community indicator](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz/prs/5000/selected-community-indicator-v2.png) ## Test plan - `pnpm exec biome check src/features/sidebar/ui/CommunityRail.tsx tests/e2e/community-rail.spec.ts` - `pnpm test` (4,387 passed) - `pnpm build:e2e && pnpm exec playwright test tests/e2e/community-rail.spec.ts --project=smoke` (20 passed) - pre-push hooks: desktop check and 4,387 desktop tests passed on `c1e80c66d12f73c4eb5c03a19e932439b14caf2d` Signed-off-by: Wes Co-authored-by: Carl --- .../src/features/sidebar/ui/CommunityRail.tsx | 9 ++++++++- desktop/tests/e2e/community-rail.spec.ts | 20 ++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index a572bb8eb6..5394065b19 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -133,10 +133,17 @@ function CommunityButton({ {...dragAttributes} {...dragListeners} > + {isActive ? ( +

    +
  1. + +
    +

    Scan QR code

    +

    + Open Buzz on your mobile device and scan the code shown here. +

    +
    +
  2. + +
  3. + +
    +

    Confirm mobile code

    +

    + Check that the six-digit code matches on both devices, then confirm + it. +

    +
    +
  4. + +
  5. + +
    +

    + {isPaired ? "Paired" : "Pair your mobile app"} +

    +

    + {isPaired + ? "Your mobile app is now connected to this relay." + : "Your mobile app will connect after you confirm the code."} +

    +
    +
  6. +
+ ); +} + +function PairingCodeConfirmation({ onConfirm, onDeny, sasCode, - step, }: { - onClose: () => void; onConfirm: () => void; onDeny: () => void; - sasCode: string | null; - step: PairingStep; + sasCode: string; }) { - const open = step === "sas" || step === "transferring" || step === "done"; + const formattedCode = `${sasCode.slice(0, 3)} ${sasCode.slice(3, 6)}`; return ( - { - if (!nextOpen) onClose(); - }} - open={open} +
- -
- - Pair mobile device - - {step === "sas" - ? "Verify the security code matches your mobile device." - : step === "done" - ? "Your mobile device is now paired." - : "Securely sending your identity to the mobile app."} - - - -
- {step === "sas" && sasCode ? ( -
-
- -

- Verify this code matches your mobile device -

-
-

- {sasCode.slice(0, 3)} {sasCode.slice(3)} -

-
-

- You are about to transfer your Buzz identity to another - device. Only confirm if you initiated this pairing. -

-
- -
- - -
-
- ) : step === "transferring" ? ( -
-
- ) : step === "done" ? ( -
-
- -
-

Mobile device paired

-

- Your mobile app is now connected to this relay. -

-
- ) : null} -
-
-
-
+ Confirm mobile code +

+
+ Confirmation code {formattedCode} + {PAIRING_CODE_DIGIT_POSITIONS.map((position) => ( + + ))} +
+
+ + +
+ ); } @@ -312,21 +382,6 @@ export function MobilePairingCard({ setStep("error"); } - function handleStatusDialogClose() { - pairingActiveRef.current = false; - if (stepRef.current === "done") { - setStep("idle"); - setQrUri(null); - setSasCode(null); - setError(null); - return; - } - - cancelPairing().catch(() => {}); - setError("Pairing was canceled."); - setStep("error"); - } - return (
- -
- {step === "qr" && qrUri ? ( - - ) : step === "expired" ? ( -
-

- Pairing code expired. -

+ {/* Persistent polite live region. The pairing steps swap the QR view + for the inline code confirmation asynchronously, and a screen + reader would otherwise get no signal that a code is now waiting. + This stays mounted for every step so the announcement is reliable + (a region added at the same time as its text often isn't spoken) + and is visually hidden, so it changes nothing on screen. */} +

+ {step === "sas" && sasCode + ? `Verification code ${sasCode.slice(0, 3)} ${sasCode.slice(3, 6)} ready. Check that it matches on your mobile device, then confirm the codes match.` + : step === "transferring" + ? "Codes confirmed. Pairing your mobile device." + : step === "done" + ? "Your mobile app is now paired." + : ""} +

+ +
+
+ {step === "sas" && sasCode ? ( + void handleConfirmSas()} + onDeny={handleDenySas} + sasCode={sasCode} + /> + ) : step === "transferring" ? ( +
+
+ ) : step === "qr" && qrUri ? ( + + ) : step === "expired" ? ( +
+

+ Pairing code expired. +

+ +
+ ) : step === "error" ? ( +
+ +

+ {error ?? "Pairing session ended."} +

+ +
+ ) : step === "idle" ? ( + currentPubkey ? ( + + ) : ( +

+ Sign in to generate a mobile pairing code. +

+ ) + ) : step === "done" ? ( +
+
+ +
+

Paired

+
+ ) : ( +
+
+ )} +
+ +
+ {step === "qr" && qrUri ? ( -
- ) : step === "error" ? ( -
- -

- {error ?? "Pairing session ended."} -

- -
- ) : step === "idle" ? ( - currentPubkey ? ( - - ) : ( -

- Sign in to generate a mobile pairing code. -

- ) - ) : ( -
-
- )} + ) : null} +
- {step === "qr" && qrUri ? ( - - ) : null} + - - void handleConfirmSas()} - onDeny={handleDenySas} - sasCode={sasCode} - step={step} - />
); } diff --git a/desktop/tests/e2e/mobile-pairing-qr.spec.ts b/desktop/tests/e2e/mobile-pairing-qr.spec.ts index 606777545f..a1e7935e38 100644 --- a/desktop/tests/e2e/mobile-pairing-qr.spec.ts +++ b/desktop/tests/e2e/mobile-pairing-qr.spec.ts @@ -47,10 +47,50 @@ test("mobile pairing starts on demand and reveals the QR code", async ({ const section = page.getByTestId("settings-mobile"); const card = page.getByTestId("mobile-pairing-card"); + const layout = card.getByTestId("mobile-pairing-layout"); const qrContainer = page.getByTestId("mobile-pairing-qr-container"); + const steps = card.getByTestId("mobile-pairing-steps"); + const scanStepIndicator = card.getByTestId( + "mobile-pairing-scan-step-indicator", + ); + const confirmStepIndicator = card.getByTestId( + "mobile-pairing-confirm-step-indicator", + ); + const finalStep = card.getByTestId("mobile-pairing-final-step"); const startButton = card.getByTestId("start-pairing-button"); await expect(card).toBeVisible(); await expect(startButton).toHaveText("Start pairing"); + await expect(steps.getByText("Scan QR code", { exact: true })).toBeVisible(); + await expect( + steps.getByText("Confirm mobile code", { exact: true }), + ).toBeVisible(); + await expect( + finalStep.getByText("Pair your mobile app", { exact: true }), + ).toBeVisible(); + const finalStepIndicator = finalStep.getByTestId( + "mobile-pairing-final-step-indicator", + ); + await expect(finalStepIndicator).toHaveText("3"); + await expect(scanStepIndicator).toHaveAttribute("data-completed", "false"); + await expect(confirmStepIndicator).toHaveAttribute("data-completed", "false"); + await expect(finalStepIndicator).toHaveCSS("width", "48px"); + await expect(finalStepIndicator).toHaveCSS("height", "48px"); + expect( + await finalStepIndicator.evaluate((element) => + Number.parseFloat(getComputedStyle(element).borderRadius), + ), + ).toBeGreaterThanOrEqual(24); + const indicatorBackground = await finalStepIndicator.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + const primaryActionBackground = await startButton.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + expect(indicatorBackground).not.toBe(primaryActionBackground); + await expect(layout).toHaveCSS("padding-top", "60px"); + await expect(layout).toHaveCSS("padding-right", "60px"); + await expect(layout).toHaveCSS("padding-left", "60px"); + await expect(layout).toHaveCSS("column-gap", "56px"); await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0); await expect(page.getByTestId("copy-pairing-code")).toHaveCount(0); expect( @@ -64,8 +104,20 @@ test("mobile pairing starts on demand and reveals the QR code", async ({ const sectionBox = await section.boundingBox(); const cardBox = await card.boundingBox(); + const initialStepsBox = await steps.boundingBox(); expect(sectionBox).not.toBeNull(); expect(cardBox).not.toBeNull(); + expect(initialStepsBox).not.toBeNull(); + const initialQrBox = await qrContainer.boundingBox(); + expect(initialQrBox).not.toBeNull(); + const qrTopSpace = (initialQrBox?.y ?? 0) - (cardBox?.y ?? 0); + const qrLeftSpace = (initialQrBox?.x ?? 0) - (cardBox?.x ?? 0); + const qrBottomSpace = + (cardBox?.y ?? 0) + + (cardBox?.height ?? 0) - + ((initialQrBox?.y ?? 0) + (initialQrBox?.height ?? 0)); + expect(Math.abs(qrTopSpace - qrLeftSpace)).toBeLessThan(0.5); + expect(Math.abs(qrTopSpace - qrBottomSpace)).toBeLessThan(0.5); const sectionCenter = (sectionBox?.x ?? 0) + (sectionBox?.width ?? 0) / 2; const cardCenter = (cardBox?.x ?? 0) + (cardBox?.width ?? 0) / 2; expect(Math.abs(sectionCenter - cardCenter)).toBeLessThan(0.5); @@ -118,8 +170,19 @@ test("mobile pairing starts on demand and reveals the QR code", async ({ await waitForAnimations(page); const qrBox = await qrContainer.boundingBox(); const copyBox = await copyButton.boundingBox(); + const stepsBox = await steps.boundingBox(); + const qrCardBox = await card.boundingBox(); expect(qrBox).not.toBeNull(); expect(copyBox).not.toBeNull(); + expect(stepsBox).not.toBeNull(); + expect(qrCardBox).not.toBeNull(); + expect(qrBox?.x ?? 0).toBeLessThan(stepsBox?.x ?? 0); + expect(Math.abs((stepsBox?.y ?? 0) - (initialStepsBox?.y ?? 0))).toBeLessThan( + 0.5, + ); + expect( + Math.abs((qrCardBox?.height ?? 0) - (cardBox?.height ?? 0)), + ).toBeLessThan(0.5); expect(copyBox?.y ?? 0).toBeGreaterThan( (qrBox?.y ?? 0) + (qrBox?.height ?? 0), ); @@ -158,6 +221,178 @@ test("mobile pairing starts on demand and reveals the QR code", async ({ await qrCode.screenshot({ path: `${SCREENSHOT_DIR}/pairing-qr.png` }); }); +test("pairing completion updates the final step and resets after leaving", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await page.getByTestId("settings-nav-mobile").click(); + + const card = page.getByTestId("mobile-pairing-card"); + const finalStep = card.getByTestId("mobile-pairing-final-step"); + const scanStepIndicator = card.getByTestId( + "mobile-pairing-scan-step-indicator", + ); + const confirmStepIndicator = card.getByTestId( + "mobile-pairing-confirm-step-indicator", + ); + await card.getByTestId("start-pairing-button").click(); + await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible(); + + await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" }); + const confirmation = card.getByTestId("mobile-pairing-code-confirmation"); + await expect(scanStepIndicator).toHaveAttribute("data-completed", "true"); + await expect(scanStepIndicator.locator('[data-state="complete"]')).toHaveCSS( + "opacity", + "1", + ); + await expect(scanStepIndicator.locator("svg")).toHaveCount(1); + await expect(confirmStepIndicator).toHaveAttribute("data-completed", "false"); + await expect(page.getByTestId("mobile-pairing-dialog")).toHaveCount(0); + await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0); + const confirmationCode = confirmation.getByTestId("pairing-sas-code"); + await expect(confirmationCode).toHaveAccessibleName( + "Confirmation code 123 456", + ); + await expect( + confirmationCode.locator('[data-testid^="pairing-sas-code-digit-"]'), + ).toHaveCount(6); + await expect( + confirmationCode.getByTestId("pairing-sas-code-digit-1"), + ).toHaveCSS("border-radius", "12px"); + await expect( + confirmationCode.getByTestId("pairing-sas-code-digit-1"), + ).toHaveCSS("box-shadow", "none"); + const firstCodeDigit = confirmationCode.getByTestId( + "pairing-sas-code-digit-1", + ); + const firstCodeDigitBox = await firstCodeDigit.boundingBox(); + expect(firstCodeDigitBox).not.toBeNull(); + expect(firstCodeDigitBox?.width ?? 0).toBeGreaterThan(32); + expect(firstCodeDigitBox?.height ?? 0).toBeGreaterThan(48); + const secondCodeDigitBox = await confirmationCode + .getByTestId("pairing-sas-code-digit-2") + .boundingBox(); + const thirdCodeDigitBox = await confirmationCode + .getByTestId("pairing-sas-code-digit-3") + .boundingBox(); + const fourthCodeDigitBox = await confirmationCode + .getByTestId("pairing-sas-code-digit-4") + .boundingBox(); + expect(secondCodeDigitBox).not.toBeNull(); + expect(thirdCodeDigitBox).not.toBeNull(); + expect(fourthCodeDigitBox).not.toBeNull(); + const regularDigitGap = + (thirdCodeDigitBox?.x ?? 0) - + ((secondCodeDigitBox?.x ?? 0) + (secondCodeDigitBox?.width ?? 0)); + const groupedDigitGap = + (fourthCodeDigitBox?.x ?? 0) - + ((thirdCodeDigitBox?.x ?? 0) + (thirdCodeDigitBox?.width ?? 0)); + expect(groupedDigitGap).toBeGreaterThan(regularDigitGap); + await expect(card.getByTestId("mobile-pairing-qr-container")).toHaveCSS( + "border-color", + "rgba(0, 0, 0, 0)", + ); + const confirmButton = confirmation.getByTestId("confirm-sas"); + const cancelButton = confirmation.getByTestId("deny-sas"); + const confirmationBox = await confirmation.boundingBox(); + const confirmationTitleBox = await confirmation + .getByTestId("pairing-sas-title") + .boundingBox(); + const confirmationCodeBox = await confirmationCode.boundingBox(); + const confirmationActionsBox = await confirmation + .getByTestId("pairing-sas-actions") + .boundingBox(); + expect(confirmationBox).not.toBeNull(); + expect(confirmationTitleBox).not.toBeNull(); + expect(confirmationCodeBox).not.toBeNull(); + expect(confirmationActionsBox).not.toBeNull(); + expect( + Math.abs((confirmationTitleBox?.y ?? 0) - (confirmationBox?.y ?? 0)), + ).toBeLessThan(0.5); + const codeCenter = + (confirmationCodeBox?.y ?? 0) + (confirmationCodeBox?.height ?? 0) / 2; + const availableCodeCenter = + ((confirmationTitleBox?.y ?? 0) + + (confirmationTitleBox?.height ?? 0) + + (confirmationActionsBox?.y ?? 0)) / + 2; + expect(Math.abs(availableCodeCenter - codeCenter)).toBeLessThan(0.5); + expect( + Math.abs( + (confirmationActionsBox?.y ?? 0) + + (confirmationActionsBox?.height ?? 0) - + ((confirmationBox?.y ?? 0) + (confirmationBox?.height ?? 0)), + ), + ).toBeLessThan(0.5); + await expect(confirmButton).toHaveCSS("height", "36px"); + await expect(cancelButton).toHaveCSS("height", "36px"); + await expect(confirmButton.locator("svg")).toHaveCount(1); + await expect(cancelButton.locator("svg")).toHaveCount(0); + const confirmBox = await confirmButton.boundingBox(); + const cancelBox = await cancelButton.boundingBox(); + expect(confirmBox).not.toBeNull(); + expect(cancelBox).not.toBeNull(); + expect(confirmBox?.y ?? 0).toBeLessThan(cancelBox?.y ?? 0); + await expect( + confirmation.getByText(/Only confirm if you started this pairing/), + ).toHaveCount(0); + await expect(confirmation.getByTestId("pairing-sas-title")).toHaveCSS( + "font-size", + "16px", + ); + + mkdirSync(SCREENSHOT_DIR, { recursive: true }); + await waitForAnimations(page); + await card.screenshot({ + path: `${SCREENSHOT_DIR}/pairing-code-confirmation.png`, + }); + + await confirmation.getByTestId("confirm-sas").click(); + await expect(card.getByText("Pairing mobile device...")).toBeVisible(); + await expect(confirmStepIndicator).toHaveAttribute("data-completed", "true"); + await expect( + confirmStepIndicator.locator('[data-state="complete"]'), + ).toHaveCSS("opacity", "1"); + await expect(confirmStepIndicator.locator("svg")).toHaveCount(1); + + await emitPairingEvent(page, "pairing-complete"); + + await expect(finalStep.getByText("Paired", { exact: true })).toBeVisible(); + await expect( + finalStep.getByText("Your mobile app is now connected to this relay."), + ).toBeVisible(); + await expect( + finalStep.getByTestId("mobile-pairing-final-step-indicator").locator("svg"), + ).toHaveCount(1); + await expect( + finalStep.getByTestId("mobile-pairing-final-step-indicator"), + ).toHaveAttribute("data-completed", "true"); + const pairedSurface = card.getByTestId("mobile-pairing-qr-container"); + await expect( + pairedSurface.getByText("Paired", { exact: true }), + ).toBeVisible(); + await expect( + pairedSurface.getByText("Your mobile app is now connected to this relay."), + ).toHaveCount(0); + + await waitForAnimations(page); + await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-complete.png` }); + + await page.getByTestId("settings-nav-updates").click(); + await page.getByTestId("settings-nav-mobile").click(); + + const restartedCard = page.getByTestId("mobile-pairing-card"); + await expect(restartedCard.getByTestId("start-pairing-button")).toBeVisible(); + await expect( + restartedCard + .getByTestId("mobile-pairing-final-step") + .getByText("Pair your mobile app", { exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0); +}); + test("late pairing events are ignored after canceling", async ({ page }) => { await page.goto("/"); await page.getByTestId("open-settings").click(); @@ -169,17 +404,48 @@ test("late pairing events are ignored after canceling", async ({ page }) => { await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible(); await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" }); - const dialog = page.getByTestId("mobile-pairing-dialog"); - await expect(dialog).toBeVisible(); - await dialog.getByRole("button", { name: "Close" }).click(); + const confirmation = card.getByTestId("mobile-pairing-code-confirmation"); + await expect(confirmation).toBeVisible(); + await confirmation.getByTestId("deny-sas").click(); - await expect(dialog).toHaveCount(0); - await expect(card.getByText("Pairing was canceled.")).toBeVisible(); + await expect(confirmation).toHaveCount(0); + await expect( + card.getByText("The codes didn't match. Pairing was canceled."), + ).toBeVisible(); await emitPairingEvent(page, "pairing-complete"); await emitPairingEvent(page, "pairing-sas-received", { sas: "654321" }); - await expect(dialog).toHaveCount(0); - await expect(page.getByTestId("mobile-pairing-done")).toHaveCount(0); - await expect(card.getByText("Pairing was canceled.")).toBeVisible(); + await expect(confirmation).toHaveCount(0); + await expect( + card + .getByTestId("mobile-pairing-final-step") + .getByText("Paired", { exact: true }), + ).toHaveCount(0); + await expect( + card.getByText("The codes didn't match. Pairing was canceled."), + ).toBeVisible(); +}); + +test("step completion respects reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await page.getByTestId("settings-nav-mobile").click(); + + const card = page.getByTestId("mobile-pairing-card"); + const scanStepIndicator = card.getByTestId( + "mobile-pairing-scan-step-indicator", + ); + await card.getByTestId("start-pairing-button").click(); + await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible(); + + await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" }); + + await expect(scanStepIndicator).toHaveAttribute("data-completed", "true"); + await expect(scanStepIndicator).toHaveCSS("transition-property", "none"); + const completedContent = scanStepIndicator.locator('[data-state="complete"]'); + await expect(completedContent).toHaveCSS("opacity", "1"); + await expect(completedContent).toHaveCSS("transform", "none"); }); From 9213090f6076bf3b7667b9b984752b3e47ef8f2f Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Thu, 6 Aug 2026 08:04:39 -0700 Subject: [PATCH 07/16] test(desktop): await thread scroll anchor (#3174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The focus/split E2E test could capture the thread root before its programmatic middle-thread scroll had settled, then incorrectly report a scroll-restoration failure. ## What - Poll until the requested middle-thread scroll position is applied - Require the captured anchor to intersect the thread viewport and differ from the root - Preserve the existing focus-to-split-to-focus viewport assertions ## Risk Assessment Low — test-only synchronization change with no production behavior changes. ## References - Original failure: https://github.com/block/buzz/actions/runs/30231271427/job/89870533541 - Buzz thread: buzz://message?channel=12dd513d-45fd-48ff-80ac-8596d2fcc9d3&id=87ce6024b4bf74bfac2fa75d9f7bbbcc8f8fe2df460afe534152c495929f51ba - Reproduced confidence: 20 consecutive targeted passes, full spec pass, `just desktop-ci`, and `just ci` Generated with Codex Signed-off-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz> Co-authored-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz> Co-authored-by: Wes --- desktop/tests/e2e/thread-focus-mode.spec.ts | 44 ++++++++++++++------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/desktop/tests/e2e/thread-focus-mode.spec.ts b/desktop/tests/e2e/thread-focus-mode.spec.ts index 8bb9a3b52b..36eb0630ba 100644 --- a/desktop/tests/e2e/thread-focus-mode.spec.ts +++ b/desktop/tests/e2e/thread-focus-mode.spec.ts @@ -30,17 +30,37 @@ async function seedLongThread(page: import("@playwright/test").Page) { }); } -async function topVisibleMessageId( +async function scrollToMiddleVisibleMessage( body: import("@playwright/test").Locator, + threadRootId: string, ): Promise { - return body.evaluate((element) => { - const top = element.getBoundingClientRect().top; - const row = Array.from( - element.querySelectorAll("[data-message-id]"), - ).find((candidate) => candidate.getBoundingClientRect().bottom > top); - if (!row?.dataset.messageId) throw new Error("No visible thread anchor"); - return row.dataset.messageId; - }); + let anchorId: string | null = null; + await expect + .poll(async () => { + anchorId = await body.evaluate((element) => { + const maxScrollTop = element.scrollHeight - element.clientHeight; + if (maxScrollTop <= 0) return null; + + const targetScrollTop = Math.floor(maxScrollTop * 0.4); + element.scrollTop = targetScrollTop; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + + if (Math.abs(element.scrollTop - targetScrollTop) > 1) return null; + const bounds = element.getBoundingClientRect(); + const row = Array.from( + element.querySelectorAll("[data-message-id]"), + ).find((candidate) => { + const rect = candidate.getBoundingClientRect(); + return rect.bottom > bounds.top && rect.top < bounds.bottom; + }); + return row?.dataset.messageId ?? null; + }); + return anchorId !== null && anchorId !== threadRootId; + }) + .toBe(true); + + if (!anchorId) throw new Error("No visible middle-thread anchor"); + return anchorId; } /** @@ -181,11 +201,7 @@ test("focus and split preserve reading context and interaction ownership", async .toBe(true); await expect(channel).toHaveAttribute("inert", ""); - await body.evaluate((element) => { - element.scrollTop = element.scrollHeight * 0.4; - element.dispatchEvent(new Event("scroll", { bubbles: true })); - }); - const anchorId = await topVisibleMessageId(body); + const anchorId = await scrollToMiddleVisibleMessage(body, rootId); const focusModeToggle = page.getByRole("button", { name: "Show thread beside channel", From 6ca9641a9555e48f99b3ebccc123ca8c25648a45 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 6 Aug 2026 16:21:35 +0100 Subject: [PATCH 08/16] Refine agent runtime controls (#5026) ## Summary - replace ambiguous avatar play controls with centered Start and Restart pills - preserve avatar clipping while smoothly morphing actions into the running status dot - use accessible warning contrast and real restart behavior without a duplicate status badge ## Validation - `just ci` - focused Playwright coverage for morphing, shared geometry, and light/dark contrast Signed-off-by: kenny lopez --- .../agents/lib/managedAgentControlActions.ts | 2 +- .../agents/managedAgentRuntimeStatus.ts | 6 +- .../agents/ui/AgentRuntimeAvatarControl.tsx | 153 ++++++--- desktop/src/features/agents/ui/AgentsView.tsx | 4 + .../agents/ui/UnifiedAgentsSection.tsx | 51 ++- .../agents/ui/useManagedAgentActions.ts | 31 ++ .../profile/ui/MaskedAvatarBadgeFrame.tsx | 220 ++++++++++++- .../profile/ui/UserProfilePrimaryActions.tsx | 4 +- desktop/tests/e2e/agents.spec.ts | 94 ++++++ .../e2e/needs-restart-screenshots.spec.ts | 294 +++++++++++------- 10 files changed, 665 insertions(+), 194 deletions(-) diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index dbaaaba803..50a92e4f17 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -44,7 +44,7 @@ export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { return "Stop"; } - return agent.status === "stopped" ? "Respawn" : "Spawn"; + return agent.status === "stopped" ? "Restart Agent" : "Start Agent"; } export function resolveManagedAgentChannelId( diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index a9f2734f21..c3a952f7d5 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -57,9 +57,9 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record< ManagedAgentPairAction, string > = { - start: "Start", - stop: "Stop", - restart: "Restart", + start: "Start Agent", + stop: "Stop Agent", + restart: "Restart Agent", }; /** diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 6f34ffba79..1b3c7a0574 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -1,7 +1,6 @@ -import { CircleAlert, Play } from "lucide-react"; +import { CircleAlert } from "lucide-react"; import { useReducedMotion } from "motion/react"; -import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { type AvatarBadgeCurve, MaskedAvatarBadgeFrame, @@ -18,8 +17,10 @@ type AgentRuntimeAvatarControlProps = { errorLabel?: string | null; errorTestId?: string; isActive: boolean; + isRestarting?: boolean; isStarting: boolean; label: string; + requiresRestart?: boolean; startTestId: string; onOpenError?: () => void; onStart: () => void; @@ -29,6 +30,7 @@ const TAILWIND_SPACING = { "1": 4, "2": 8, "2.5": 10, + "3.5": 14, "6": 24, "11": 44, "24": 96, @@ -36,44 +38,58 @@ const TAILWIND_SPACING = { const AGENT_AVATAR_SIZE = TAILWIND_SPACING["24"]; const ACTION_BADGE_SIZE = TAILWIND_SPACING["11"]; -const ACTIVE_BADGE_SIZE = TAILWIND_SPACING["6"]; -const ACTION_BADGE_OFFSET = TAILWIND_SPACING["2.5"]; +const ACTION_BUTTON_HEIGHT = 36; +const START_ACTION_BADGE_WIDTH = 56; +const RESTART_ACTION_BADGE_WIDTH = 72; +const ACTIVE_BADGE_CUTOUT_SIZE = TAILWIND_SPACING["6"]; +const ACTIVE_DOT_SIZE = 18; +const ACTION_BADGE_OFFSET = TAILWIND_SPACING["3.5"] + TAILWIND_SPACING["1"]; const ACTIVE_BADGE_INSET = TAILWIND_SPACING["1"]; -const ACTIVE_DOT_CLASS_NAME = "h-4.5 w-4.5"; const PROFILE_STATUS_CUTOUT_RATIO = 1.25; function getBadgeCenter(badgeSize: number, outwardOffset: number) { return AGENT_AVATAR_SIZE + outwardOffset - badgeSize / 2; } -function getActionBadge(offset: number) { +function getActionBadge(width: number, height: number, offset: number) { + const centerY = getBadgeCenter(ACTION_BADGE_SIZE, offset); + const clearance = (ACTION_BADGE_SIZE - height) / 2; + return { cutout: { - cx: getBadgeCenter(ACTION_BADGE_SIZE, offset), - cy: getBadgeCenter(ACTION_BADGE_SIZE, offset), + // Keep the cutout on the avatar edge so the mask has the same soft, + // two-point join as the status dot. Unlike the status dot, center the + // primary action horizontally to make its purpose easier to spot. + cx: AGENT_AVATAR_SIZE / 2, + cy: centerY, r: ACTION_BADGE_SIZE / 2, }, shell: { - bottom: -offset, - height: ACTION_BADGE_SIZE, - right: -offset, - width: ACTION_BADGE_SIZE, + bottom: AGENT_AVATAR_SIZE - centerY - height / 2, + height, + right: (AGENT_AVATAR_SIZE - width) / 2, + width, }, + // Carry the vertical clearance around the end caps horizontally too, so + // the avatar gap stays even around the pill. + cutoutWidth: width + clearance * 2, } as const; } function getActiveBadge(inset: number) { + const center = getBadgeCenter(ACTIVE_BADGE_CUTOUT_SIZE, -inset); + return { cutout: { - cx: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset), - cy: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset), - r: (ACTIVE_BADGE_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO, + cx: center, + cy: center, + r: (ACTIVE_BADGE_CUTOUT_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO, }, shell: { - bottom: inset, - height: ACTIVE_BADGE_SIZE, - right: inset, - width: ACTIVE_BADGE_SIZE, + bottom: AGENT_AVATAR_SIZE - center - ACTIVE_DOT_SIZE / 2, + height: ACTIVE_DOT_SIZE, + right: AGENT_AVATAR_SIZE - center - ACTIVE_DOT_SIZE / 2, + width: ACTIVE_DOT_SIZE, }, } as const; } @@ -87,12 +103,26 @@ const ACTION_MASK_CURVE = { handleLengthRatio: 0.26, } satisfies AvatarBadgeCurve; -const ACTION_BADGE = getActionBadge(ACTION_BADGE_OFFSET); +const START_ACTION_BADGE = getActionBadge( + START_ACTION_BADGE_WIDTH, + ACTION_BUTTON_HEIGHT, + ACTION_BADGE_OFFSET, +); +const RESTART_ACTION_BADGE = getActionBadge( + RESTART_ACTION_BADGE_WIDTH, + ACTION_BUTTON_HEIGHT, + ACTION_BADGE_OFFSET, +); +const ERROR_BADGE = getActionBadge( + ACTION_BADGE_SIZE, + ACTION_BUTTON_HEIGHT, + ACTION_BADGE_OFFSET, +); const ACTIVE_BADGE = getActiveBadge(ACTIVE_BADGE_INSET); const MASK_TRANSITION = { - duration: 0.22, - ease: [0.23, 1, 0.32, 1], + duration: 0.3, + ease: [0.4, 0, 0.2, 1], } as const; export function AgentRuntimeAvatarControl({ @@ -101,45 +131,66 @@ export function AgentRuntimeAvatarControl({ errorLabel, errorTestId, isActive, + isRestarting = false, isStarting, label, + requiresRestart = false, startTestId, onOpenError, onStart, }: AgentRuntimeAvatarControlProps) { const shouldReduceMotion = useReducedMotion(); const trimmedAvatarUrl = avatarUrl?.trim() || null; - const actionLabel = isStarting ? `Starting ${label}` : `Start ${label}`; - const hasError = !isActive && !isStarting && Boolean(errorLabel); + const isRestartAction = requiresRestart || isRestarting; + const actionLabel = isRestarting + ? "Restarting Agent" + : isStarting + ? "Starting Agent" + : isRestartAction + ? "Restart Agent" + : "Start Agent"; + const actionText = isRestartAction ? "Restart" : "Start"; + const isPending = isStarting || isRestarting; + const showRunningDot = isActive && !isRestartAction; + const hasError = !isActive && !isPending && Boolean(errorLabel); const errorActionLabel = `${label} has a runtime error. Open runtime details.`; const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION; - const badge = isActive ? ACTIVE_BADGE : ACTION_BADGE; + const actionBadge = isRestartAction + ? RESTART_ACTION_BADGE + : START_ACTION_BADGE; + const badge = showRunningDot + ? ACTIVE_BADGE + : hasError + ? ERROR_BADGE + : actionBadge; + const actionCutoutWidth = + showRunningDot || hasError ? undefined : actionBadge.cutoutWidth; return ( - {isActive ? ( + {showRunningDot ? ( - - + /> ) : ( )} } badgeBox={badge.shell} + badgeClassName={cn( + "transition-colors ease-in-out", + shouldReduceMotion ? "duration-0" : "duration-300", + showRunningDot + ? "bg-emerald-500" + : hasError + ? "bg-destructive" + : isRestartAction + ? "bg-amber-500/15" + : "bg-primary", + )} className="h-24 w-24" - curve={isActive ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE} + curve={showRunningDot ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE} cutout={badge.cutout} + cutoutWidth={actionCutoutWidth} maskTransition={transition} size={AGENT_AVATAR_SIZE} > diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 720d6e62ad..f9ada91c2f 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -225,6 +225,7 @@ export function AgentsView() { isActionPending={isActionPending} isAgentsLoading={agents.managedAgentsQuery.isLoading} startingAgentPubkey={agents.startingAgentPubkey} + restartingAgentPubkey={agents.restartingAgentPubkey} startingPersonaIds={agents.startingPersonaIds} onOpenAgentProfile={(pubkey, options) => { openProfilePanel?.(pubkey, options); @@ -235,6 +236,9 @@ export function AgentsView() { onStartAgent={(pubkey) => { void agents.handleStart(pubkey); }} + onRestartAgent={(pubkey) => { + void agents.handleRestart(pubkey); + }} onStartPersona={(persona) => { void agents.handleStartPersona(persona); }} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index cf39b0859e..212d9bc96e 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -10,7 +10,6 @@ import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelConte import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; import { Badge } from "@/shared/ui/badge"; -import { RestartDiffBadge } from "./RestartDiffBadge"; import { DropdownMenu, DropdownMenuContent, @@ -32,6 +31,7 @@ type UnifiedAgentsSectionProps = { agentsError: Error | null; isActionPending: boolean; isAgentsLoading: boolean; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; startingPersonaIds: ReadonlySet; onOpenAgentProfile: ( @@ -39,6 +39,7 @@ type UnifiedAgentsSectionProps = { options?: ProfilePanelOpenOptions, ) => void; onOpenPersonaProfile: (persona: AgentPersona) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; personas: AgentPersona[]; @@ -75,10 +76,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { agentsError, isActionPending, isAgentsLoading, + restartingAgentPubkey, startingAgentPubkey, startingPersonaIds, onOpenAgentProfile, onOpenPersonaProfile, + onRestartAgent, onStartAgent, onStartPersona, personas, @@ -175,10 +178,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} key={group.persona.id} persona={group.persona} + restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} startingPersonaIds={startingPersonaIds} onOpenAgentProfile={onOpenAgentProfile} onOpenPersonaProfile={onOpenPersonaProfile} + onRestartAgent={onRestartAgent} onStartAgent={onStartAgent} onStartPersona={onStartPersona} /> @@ -199,9 +204,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} groupKey="__unknown__" label="Unknown agents" + restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} + onRestartAgent={onRestartAgent} onStartAgent={onStartAgent} /> ) : null} @@ -212,9 +219,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} groupKey="__ungrouped__" label="Custom agents" + restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} + onRestartAgent={onRestartAgent} onStartAgent={onStartAgent} /> ) : null} @@ -244,10 +253,12 @@ function AgentPersonaCard({ agent, defaultModel, persona, + restartingAgentPubkey, startingAgentPubkey, startingPersonaIds, onOpenAgentProfile, onOpenPersonaProfile, + onRestartAgent, onStartAgent, onStartPersona, }: { @@ -258,6 +269,7 @@ function AgentPersonaCard({ agent: ManagedAgent | undefined; defaultModel: string; persona: AgentPersona; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; startingPersonaIds: ReadonlySet; onOpenAgentProfile: ( @@ -265,6 +277,7 @@ function AgentPersonaCard({ options?: ProfilePanelOpenOptions, ) => void; onOpenPersonaProfile: (persona: AgentPersona) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; }) { @@ -299,13 +312,19 @@ function AgentPersonaCard({ errorLabel={friendlyError} errorTestId={`agent-runtime-error-${agent.pubkey}`} isActive={isActive} + isRestarting={restartingAgentPubkey === agent.pubkey} isStarting={startingAgentPubkey === agent.pubkey} label={title} + requiresRestart={agent.needsRestart} startTestId={`agent-runtime-start-${agent.pubkey}`} onOpenError={() => { onOpenAgentProfile(agent.pubkey, { tab: "runtime" }); }} - onStart={() => onStartAgent(agent.pubkey)} + onStart={() => + agent.needsRestart + ? onRestartAgent(agent.pubkey) + : onStartAgent(agent.pubkey) + } /> ) : ( Configuration missing - ) : agent?.needsRestart ? ( - ) : null } /> @@ -353,17 +367,21 @@ function AgentPersonaCard({ function StandaloneAgentCard({ agent, defaultModel, + restartingAgentPubkey, startingAgentPubkey, onOpenAgentProfile, + onRestartAgent, onStartAgent, }: { agent: ManagedAgent; defaultModel: string; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; onOpenAgentProfile: ( pubkey: string, options?: ProfilePanelOpenOptions, ) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; }) { const title = agent.name; @@ -385,13 +403,19 @@ function StandaloneAgentCard({ errorLabel={friendlyError} errorTestId={`agent-runtime-error-${agent.pubkey}`} isActive={isActive} + isRestarting={restartingAgentPubkey === agent.pubkey} isStarting={startingAgentPubkey === agent.pubkey} label={title} + requiresRestart={agent.needsRestart} startTestId={`agent-runtime-start-${agent.pubkey}`} onOpenError={() => { onOpenAgentProfile(agent.pubkey, { tab: "runtime" }); }} - onStart={() => onStartAgent(agent.pubkey)} + onStart={() => + agent.needsRestart + ? onRestartAgent(agent.pubkey) + : onStartAgent(agent.pubkey) + } /> } avatarUrl={profileQuery.data?.avatarUrl} @@ -414,11 +438,6 @@ function StandaloneAgentCard({ Configuration missing - ) : agent.needsRestart ? ( - ) : null } /> @@ -498,9 +517,11 @@ function CollapsibleAgentGroup({ agents, collapsed, defaultModel, + restartingAgentPubkey, startingAgentPubkey, onToggle, onOpenAgentProfile, + onRestartAgent, onStartAgent, }: { groupKey: string; @@ -508,12 +529,14 @@ function CollapsibleAgentGroup({ agents: ManagedAgent[]; collapsed: ReadonlySet; defaultModel: string; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; onToggle: (key: string) => void; onOpenAgentProfile: ( pubkey: string, options?: ProfilePanelOpenOptions, ) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; }) { const isCollapsed = collapsed.has(groupKey); @@ -539,8 +562,10 @@ function CollapsibleAgentGroup({ agent={agent} defaultModel={defaultModel} key={agent.pubkey} + restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} onOpenAgentProfile={onOpenAgentProfile} + onRestartAgent={onRestartAgent} onStartAgent={onStartAgent} /> ))} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index e1c2e9c9fc..6068ad1639 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -26,6 +26,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import { deleteManagedAgentWithRules, isManagedAgentActive, + respawnManagedAgentWithRules, startManagedAgentWithRules, stopManagedAgentWithRules, } from "../lib/managedAgentControlActions"; @@ -57,6 +58,9 @@ export function useManagedAgentActions() { ReadonlySet >(() => new Set()); const startingPersonaIdsRef = React.useRef(new Set()); + const [restartingAgentPubkey, setRestartingAgentPubkey] = React.useState< + string | null + >(null); const [logAgentPubkey, setLogAgentPubkey] = React.useState( null, ); @@ -174,6 +178,30 @@ export function useManagedAgentActions() { } } + async function handleRestart(pubkey: string) { + if (restartingAgentPubkey) return; + clearFeedback(); + setRestartingAgentPubkey(pubkey); + try { + const agent = managedAgents.find( + (candidate) => candidate.pubkey === pubkey, + ); + if (!agent) return; + await respawnManagedAgentWithRules({ + agent, + startManagedAgent: startMutation.mutateAsync, + stopManagedAgent: stopMutation.mutateAsync, + onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey), + }); + } catch (error) { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to restart agent.", + ); + } finally { + setRestartingAgentPubkey(null); + } + } + function setPersonaStartPending(personaId: string, pending: boolean) { const next = new Set(startingPersonaIdsRef.current); if (pending) { @@ -387,6 +415,7 @@ export function useManagedAgentActions() { } const isPending = + restartingAgentPubkey !== null || createAgentMutation.isPending || startMutation.isPending || stopMutation.isPending || @@ -420,8 +449,10 @@ export function useManagedAgentActions() { actionErrorMessage, setActionErrorMessage, startingAgentPubkey, + restartingAgentPubkey, startingPersonaIds, handleStart, + handleRestart, handleStartPersona, handleStop, handleDelete, diff --git a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx index 294a87e5e2..ea6e234493 100644 --- a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx +++ b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx @@ -43,12 +43,14 @@ type BadgeMotionTarget = { type MaskedAvatarBadgeFrameProps = { badge?: React.ReactNode; badgeBox?: AvatarBadgeBox; + badgeClassName?: string; children: React.ReactNode; className?: string; clipTestId?: string; cornerRadius?: number; curve?: AvatarBadgeCurve; cutout?: AvatarBadgeCircle; + cutoutWidth?: number; maskMode?: "clip-path" | "radial"; maskTransition?: React.ComponentProps["transition"]; size: number; @@ -323,6 +325,30 @@ function sampleArc( ); } +function sampleStableOuterBoundary( + avatar: AvatarBadgeCircle, + startAngle: number, + endAngle: number, + direction: 1 | -1, + largeArc: boolean, + segments: number, +) { + const outerBoundary = { ...avatar, r: avatar.r * 4 }; + + return [ + getPointOnCircle(outerBoundary, startAngle), + ...sampleArc( + outerBoundary, + startAngle, + endAngle, + direction, + largeArc, + segments - 2, + ), + getPointOnCircle(avatar, endAngle), + ]; +} + function toPolygonPoint(point: Point, size: number) { return `${toPercent(point.x / size)} ${toPercent(point.y / size)}`; } @@ -331,6 +357,7 @@ function getRoundedAvatarMaskPolygon( size: number, cutout: AvatarBadgeCircle, curve?: AvatarBadgeCurve, + stabilizeOuterBoundary = false, ) { const { avatar, @@ -361,14 +388,23 @@ function getRoundedAvatarMaskPolygon( avatarUpper, 12, ), - ...sampleArc( - avatar, - getAngle(avatar, avatarUpper), - getAngle(avatar, avatarLower), - -1, - true, - 96, - ), + ...(stabilizeOuterBoundary + ? sampleStableOuterBoundary( + avatar, + getAngle(avatar, avatarUpper), + getAngle(avatar, avatarLower), + -1, + true, + 96, + ) + : sampleArc( + avatar, + getAngle(avatar, avatarUpper), + getAngle(avatar, avatarLower), + -1, + true, + 96, + )), ...sampleCubic( avatarLower, getControlPoint(avatarLower, lowerAvatarTangent, lowerHandleLength), @@ -389,6 +425,141 @@ function getRoundedAvatarMaskPolygon( return `polygon(${points.map((point) => toPolygonPoint(point, size)).join(", ")})`; } +function getRoundedAvatarCapsuleMaskPolygon( + size: number, + cutout: AvatarBadgeCircle, + cutoutWidth: number, + curve?: AvatarBadgeCurve, + stabilizeOuterBoundary = false, +) { + const resolvedCurve = { ...DEFAULT_AVATAR_BADGE_CURVE, ...curve }; + const avatar = { + cx: size / 2, + cy: size / 2, + r: size / 2, + }; + const straightHalfWidth = Math.max(0, cutoutWidth / 2 - cutout.r); + const leftCap = { + cx: cutout.cx - straightHalfWidth, + cy: cutout.cy, + r: cutout.r, + }; + const rightCap = { + cx: cutout.cx + straightHalfWidth, + cy: cutout.cy, + r: cutout.r, + }; + const leftIntersection = getCircleIntersections(avatar, leftCap).reduce( + (leftmost, point) => (point.x < leftmost.x ? point : leftmost), + ); + const rightIntersection = getCircleIntersections(avatar, rightCap).reduce( + (rightmost, point) => (point.x > rightmost.x ? point : rightmost), + ); + const cutoutRoundingAngle = Math.min( + resolvedCurve.cutoutRoundingMaxAngle, + Math.max( + resolvedCurve.cutoutRoundingMinAngle, + resolvedCurve.cutoutRoundingLength / cutout.r, + ), + ); + const avatarLeft = getPointOnCircle( + avatar, + getAngle(avatar, leftIntersection) + resolvedCurve.avatarRoundingAngle, + ); + const avatarRight = getPointOnCircle( + avatar, + getAngle(avatar, rightIntersection) - resolvedCurve.avatarRoundingAngle, + ); + const cutoutLeft = getPointOnCircle( + leftCap, + getAngle(leftCap, leftIntersection) + cutoutRoundingAngle, + ); + const cutoutRight = getPointOnCircle( + rightCap, + getAngle(rightCap, rightIntersection) - cutoutRoundingAngle, + ); + const leftHandleLength = Math.min( + cutout.r * resolvedCurve.handleLengthRatio, + getDistance(cutoutLeft, avatarLeft) * resolvedCurve.handleDistanceRatio, + ); + const rightHandleLength = Math.min( + cutout.r * resolvedCurve.handleLengthRatio, + getDistance(avatarRight, cutoutRight) * resolvedCurve.handleDistanceRatio, + ); + const cutoutLeftTangent = getTangent(getAngle(leftCap, cutoutLeft), -1); + const avatarLeftTangent = getTangent(getAngle(avatar, avatarLeft), 1); + const avatarRightTangent = getTangent(getAngle(avatar, avatarRight), 1); + const cutoutRightTangent = getTangent(getAngle(rightCap, cutoutRight), -1); + const points = [ + cutoutLeft, + ...sampleCubic( + cutoutLeft, + getControlPoint(cutoutLeft, cutoutLeftTangent, leftHandleLength), + getControlPoint(avatarLeft, avatarLeftTangent, -leftHandleLength), + avatarLeft, + 12, + ), + ...(stabilizeOuterBoundary + ? sampleStableOuterBoundary( + avatar, + getAngle(avatar, avatarLeft), + getAngle(avatar, avatarRight), + 1, + true, + 96, + ) + : sampleArc( + avatar, + getAngle(avatar, avatarLeft), + getAngle(avatar, avatarRight), + 1, + true, + 96, + )), + ...sampleCubic( + avatarRight, + getControlPoint(avatarRight, avatarRightTangent, rightHandleLength), + getControlPoint(cutoutRight, cutoutRightTangent, -rightHandleLength), + cutoutRight, + 12, + ), + ...sampleArc( + rightCap, + getAngle(rightCap, cutoutRight), + -Math.PI / 2, + -1, + false, + 12, + ), + { x: leftCap.cx, y: cutout.cy - cutout.r }, + ...sampleArc( + leftCap, + -Math.PI / 2, + getAngle(leftCap, cutoutLeft), + -1, + false, + 11, + ), + ]; + + // Keep the capsule contour aligned with the circular status cutout's point + // order. Matching like-for-like edges prevents the polygon from folding + // across the avatar while Motion interpolates between the two shapes. + const joinSegments = 12; + const outerSegments = 96; + const outerEndIndex = joinSegments + outerSegments; + const rightJoinEndIndex = joinSegments * 2 + outerSegments; + const alignedPoints = [ + points[rightJoinEndIndex], + ...points.slice(outerEndIndex, rightJoinEndIndex).reverse(), + ...points.slice(joinSegments, outerEndIndex).reverse(), + ...points.slice(0, joinSegments).reverse(), + ...points.slice(rightJoinEndIndex + 1).reverse(), + ]; + + return `polygon(${alignedPoints.map((point) => toPolygonPoint(point, size)).join(", ")})`; +} + function getRoundedSquareMaskPolygon( size: number, cornerRadius: number, @@ -488,20 +659,36 @@ function getRoundedSquareMaskPolygon( export function MaskedAvatarBadgeFrame({ badge, badgeBox, + badgeClassName, children, className, clipTestId, cornerRadius, curve, cutout, + cutoutWidth, maskMode = "clip-path", maskTransition, size, }: MaskedAvatarBadgeFrameProps) { const shouldMask = Boolean(badge && badgeBox && cutout); + const stabilizeOuterBoundary = Boolean(maskTransition); const maskPolygon = cutout ? cornerRadius === undefined - ? getRoundedAvatarMaskPolygon(size, cutout, curve) + ? cutoutWidth && cutoutWidth > cutout.r * 2 + ? getRoundedAvatarCapsuleMaskPolygon( + size, + cutout, + cutoutWidth, + curve, + stabilizeOuterBoundary, + ) + : getRoundedAvatarMaskPolygon( + size, + cutout, + curve, + stabilizeOuterBoundary, + ) : getRoundedSquareMaskPolygon(size, cornerRadius, cutout, curve) : undefined; const radialMask = @@ -539,10 +726,18 @@ export function MaskedAvatarBadgeFrame({ data-testid={clipTestId} initial={false} style={{ - WebkitClipPath: radialMask ? undefined : maskPolygon, + // WebKit otherwise applies the prefixed path immediately while the + // unprefixed path is still animating, which briefly tears the avatar. + WebkitClipPath: + radialMask || maskTransition ? undefined : maskPolygon, WebkitMaskImage: radialMask, + backfaceVisibility: + maskTransition && !radialMask ? "hidden" : undefined, clipPath: radialMask ? undefined : maskPolygon, maskImage: radialMask, + transform: + maskTransition && !radialMask ? "translateZ(0)" : undefined, + willChange: maskTransition && !radialMask ? "clip-path" : undefined, }} transition={maskTransition} > @@ -551,7 +746,10 @@ export function MaskedAvatarBadgeFrame({ @@ -143,7 +143,7 @@ export function ProfilePersonaPrimaryActions({ diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 3cbe097c05..befe2b5563 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2437,6 +2437,100 @@ test("personas referenced by teams cannot be deleted", async ({ page }) => { ); }); +test("start pill morphs into the running dot without remounting the avatar", async ({ + page, +}) => { + const personaId = "custom:motion-auditor"; + const pubkey = "ab".repeat(32); + const activeDotSize = 18; + await page.emulateMedia({ reducedMotion: "no-preference" }); + await installMockBridge(page, { + personas: [ + { + avatarUrl: emojiAvatarDataUrl("✨", "#7657FF"), + displayName: "Motion Auditor", + id: personaId, + systemPrompt: "You audit motion continuity.", + }, + ], + managedAgents: [ + { + name: "Motion Auditor", + personaId, + pubkey, + status: "stopped", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const card = page.getByTestId(`persona-agent-row-${personaId}`); + const startButton = page.getByTestId(`agent-runtime-start-${pubkey}`); + const badge = startButton.locator("xpath=../.."); + const initialAvatar = await card + .getByAltText("Motion Auditor avatar") + .elementHandle(); + expect(initialAvatar).not.toBeNull(); + + const samplesPromise = badge.evaluate(async (element) => { + const samples: Array<{ + backgroundColor: string; + height: number; + width: number; + }> = []; + const startedAt = performance.now(); + + while (performance.now() - startedAt < 440) { + const bounds = element.getBoundingClientRect(); + samples.push({ + backgroundColor: getComputedStyle(element).backgroundColor, + height: bounds.height, + width: bounds.width, + }); + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + + return samples; + }); + + await page.waitForTimeout(32); + await startButton.click(); + await expect( + page.getByTestId(`agent-runtime-active-${pubkey}`), + ).toBeVisible(); + const samples = await samplesPromise; + const finalAvatar = await card + .getByAltText("Motion Auditor avatar") + .elementHandle(); + + expect(samples[0]?.width).toBeCloseTo(56, 0); + expect(samples[0]?.height).toBeCloseTo(36, 0); + expect( + samples.some( + (sample) => + sample.width > activeDotSize && + sample.width < 56 && + sample.height > activeDotSize && + sample.height < 36, + ), + ).toBe(true); + expect(samples.at(-1)?.width).toBeCloseTo(activeDotSize, 0); + expect(samples.at(-1)?.height).toBeCloseTo(activeDotSize, 0); + expect(samples.at(-1)?.backgroundColor).not.toBe(samples[0]?.backgroundColor); + await expect( + page.getByTestId(`agent-runtime-active-${pubkey}`).locator("xpath=../.."), + ).toHaveClass(/bg-emerald-500/); + expect( + await initialAvatar?.evaluate( + (before, after) => before === after, + finalAvatar, + ), + ).toBe(true); +}); + test("duplicate instances move from the agents gallery into the agent profile", async ({ page, }) => { diff --git a/desktop/tests/e2e/needs-restart-screenshots.spec.ts b/desktop/tests/e2e/needs-restart-screenshots.spec.ts index 1a695844fb..508a5d91e9 100644 --- a/desktop/tests/e2e/needs-restart-screenshots.spec.ts +++ b/desktop/tests/e2e/needs-restart-screenshots.spec.ts @@ -3,8 +3,8 @@ * overlay work). * * Exercises: - * - Agent grid card and list-row badges at all three badge sites. - * - Hover tooltip with itemised before→after diff (capped at 6 + "and N more"). + * - Agent grid restart actions without a duplicate status badge. + * - Profile badge tooltip with itemised before→after diff. * - Runtime-tab banner with full uncapped diff list. * - Side-panel badge visible on the default (Info) tab — not only Runtime. * - DOM validity: tooltip trigger has no + + + + ) : step === "done" ? ( +
+
+ +
+

Identity received securely

+
+ ) : step === "error" ? ( +
+ +

{error}

+ +
+ ) : ( +
+ +

+ {step === "receiving" + ? "Receiving identity from mobile device..." + : "Starting pairing..."} +

+
+ )} + + {step === "loading" || (step === "qr" && qrUri) ? ( + + ) : null} + {step === "qr" && error ? ( +

+ {error} +

+ ) : null} + {step === "qr" || step === "loading" ? ( +

+ On your phone, open Settings → Send identity to desktop. This code + expires shortly and works once. +

+ ) : null} + + ); +} diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 693d1af058..c0cc2d8f79 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -10,6 +10,12 @@ import { } from "@/shared/api/tauriIdentity"; import type { IdentityStorage } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/shared/ui/dialog"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; @@ -20,12 +26,14 @@ import { useEncryptedBackupSession, } from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; +import { IdentityRecoveryPairing } from "./IdentityRecoveryPairing"; import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm, type NostrKeyImportStage, } from "./NostrKeyImportForm"; import { + ONBOARDING_INK_ICON_CLASS, ONBOARDING_LANDING_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, OnboardingChrome, @@ -53,6 +61,7 @@ export type PostOnboardingNavigation = { export function MachineOnboardingFlow({ complete, continueWithIdentity, + continueWithRecoveredIdentity, identityLost, initialPage, queryClient, @@ -60,6 +69,7 @@ export function MachineOnboardingFlow({ }: { complete: (pubkey?: string) => void; continueWithIdentity: (pubkey: string) => void; + continueWithRecoveredIdentity: (pubkey: string) => void; identityLost: boolean; initialPage?: MachineOnboardingPage; queryClient: QueryClient; @@ -79,6 +89,10 @@ export function MachineOnboardingFlow({ const [identityWasImported, setIdentityWasImported] = React.useState(false); const [keyImportStage, setKeyImportStage] = React.useState("key-entry"); + const [keyImportDialog, setKeyImportDialog] = React.useState< + "backup" | "phone" | null + >(null); + const [phoneRecoveryStep, setPhoneRecoveryStep] = React.useState("loading"); const [selectedPubkey, setSelectedPubkey] = React.useState( null, ); @@ -128,6 +142,26 @@ export function MachineOnboardingFlow({ } }, [queryClient]); + const loadRecoveredIdentity = React.useCallback(async () => { + setIsPending(true); + setError(null); + try { + const identity = await getIdentity(); + continueWithRecoveredIdentity(identity.pubkey); + queryClient.setQueryData(["identity"], identity); + setIdentityWasImported(true); + setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setPage("setup"); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Failed to load identity", + ); + } finally { + setIsPending(false); + } + }, [continueWithRecoveredIdentity, queryClient]); + const replaceLostIdentity = React.useCallback(async () => { const confirmed = window.confirm( "This will create a new identity and abandon your previous key. This cannot be undone. Continue?", @@ -243,6 +277,7 @@ export function MachineOnboardingFlow({ className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`} disabled={isPending} onClick={() => { + setKeyImportDialog(null); setKeyImportStage("key-entry"); setPage("key-import"); }} @@ -265,7 +300,7 @@ export function MachineOnboardingFlow({ > {keyImportStage === "backup-password" ? "Unlock your account" - : identityLost - ? "Re-import your key" - : "Enter your private key"} + : "Enter your private key"} -

- {keyImportStage === "backup-password" - ? "Enter your backup password to unlock your key and restore your identity." - : identityLost - ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it." - : "If you already have a Buzz account, enter your private key below to get started."} -

+
+ {keyImportStage === "backup-password" ? ( + "Enter your backup password to restore your identity." + ) : ( +

+ Paste your private key to sign in to Buzz. You can also + use a{" "} + + , or{" "} + + . +

+ )} +
- void replaceLostIdentity() - : () => setPage("identity") - } - onImport={importExistingIdentity} - onStageChange={setKeyImportStage} - variant="spotlight" - /> +
+ { + setKeyImportStage("key-entry"); + if (identityLost) { + return; + } + setPage("identity"); + }} + onImport={importExistingIdentity} + onStageChange={setKeyImportStage} + showBack={!identityLost} + variant="spotlight" + /> + {identityLost && keyImportStage === "key-entry" ? ( + + ) : null} +
+ { + if (!open) setKeyImportDialog(null); + }} + open={keyImportDialog === "backup"} + > + +
+ + Restore from a backup file + + + Choose the encrypted backup file you saved from Buzz. + + setKeyImportDialog(null)} + onImport={importExistingIdentity} + showBack={false} + variant="spotlight" + /> +
+
+
+ { + if (!open) setKeyImportDialog(null); + }} + open={keyImportDialog === "phone"} + > + +
+ + {identityLost + ? "Recover from your phone" + : "Use your Buzz identity"} + + + {phoneRecoveryStep === "loading" || + phoneRecoveryStep === "qr" + ? "Scan this code with a signed-in Buzz phone." + : "Confirm the code before sharing your identity."} + +
+ +
+
+
+
) : page === "backup" ? ( backupSubview === "password" ? ( diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 59e5bfdb0b..a424236eb6 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Check, Eye, EyeOff, KeyRound } from "lucide-react"; +import { Check, Eye, EyeOff, FileKey2, KeyRound } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { nsecToNpub } from "@/shared/lib/nostrUtils"; @@ -16,7 +16,10 @@ import { ONBOARDING_PRIMARY_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, } from "./OnboardingChrome"; -import { BackupPasswordTimeline } from "./BackupPasswordTimeline"; +import { + BackupFileUnlockPreview, + BackupPasswordTimeline, +} from "./BackupPasswordTimeline"; import { OnboardingFooter } from "./OnboardingFooter"; const NOSTR_KEY_FILE_MAX_BYTES = 1024; @@ -30,6 +33,11 @@ type NostrKeyImportFormProps = { onBack: () => void; onImport: (nsec: string, password?: string) => Promise; onStageChange?: (stage: NostrKeyImportStage) => void; + showBack?: boolean; + /** Restrict this instance to selecting a backup file instead of typing a key. */ + mode?: "key" | "backup"; + /** Dialogs keep their actions inside the surface instead of the onboarding dock. */ + footerMode?: "onboarding" | "inline"; /** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */ variant?: "default" | "spotlight"; }; @@ -48,6 +56,9 @@ export function NostrKeyImportForm({ onBack, onImport, onStageChange, + showBack = true, + mode = "key", + footerMode = "onboarding", variant = "default", }: NostrKeyImportFormProps) { const [nsecInput, setNsecInput] = React.useState(""); @@ -55,6 +66,7 @@ export function NostrKeyImportForm({ const [isImporting, setIsImporting] = React.useState(false); const [importError, setImportError] = React.useState(null); const [isDragging, setIsDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); const [isRevealed, setIsRevealed] = React.useState(false); const inputRef = React.useRef(null); const passphraseInputRef = React.useRef(null); @@ -89,6 +101,7 @@ export function NostrKeyImportForm({ previewNpub === null && trimmedInput.length >= 5; const errorMessage = importError ?? externalErrorMessage; + const Footer = footerMode === "inline" ? "div" : OnboardingFooter; React.useLayoutEffect(() => { if (isPasswordStage) { @@ -102,6 +115,39 @@ export function NostrKeyImportForm({ onStageChange?.(isPasswordStage ? "backup-password" : "key-entry"); }, [isPasswordStage, onStageChange]); + React.useEffect(() => { + if (mode !== "backup" || isPasswordStage || isInteractionDisabled) { + dragDepthRef.current = 0; + setIsDragging(false); + return; + } + + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsDragging(false); + }; + + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, [isInteractionDisabled, isPasswordStage, mode]); + const openFilePicker = React.useCallback(() => { if (isInteractionDisabled) { return; @@ -194,12 +240,27 @@ export function NostrKeyImportForm({ return (
{ + if (mode !== "backup" || isPasswordStage) return; + event.preventDefault(); + if (!isInteractionDisabled) { + event.dataTransfer.dropEffect = "copy"; + } + }} + onDrop={(event) => { + if (mode !== "backup" || isPasswordStage) return; + event.preventDefault(); + setIsDragging(false); + if (!isInteractionDisabled) { + void handleFiles(event.dataTransfer.files); + } + }} onSubmit={(event) => { event.preventDefault(); void handleSubmit(); }} > - {!isPasswordStage ? ( + {!isPasswordStage && mode === "key" ? (
+ {isDragging ? ( +
+ + +
+ ) : null} + + ) : null} + + {!isPasswordStage && mode === "key" && variant !== "spotlight" ? ( +
+ {mode === "key" || isPasswordStage ? ( + + ) : null} - - + {showBack || isPasswordStage ? ( + + ) : null} +
); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 67ba582a1d..6e29f77c14 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1126,8 +1126,6 @@ export async function nip44DecryptFromSelf( return invokeTauri("nip44_decrypt_from_self", { ciphertext }); } -// ── NIP-AB device pairing ─────────────────────────────────────────────────── - export async function startPairing(): Promise { return invokeTauri("start_pairing"); } diff --git a/desktop/src/shared/api/tauriPairing.ts b/desktop/src/shared/api/tauriPairing.ts new file mode 100644 index 0000000000..6fdf779446 --- /dev/null +++ b/desktop/src/shared/api/tauriPairing.ts @@ -0,0 +1,5 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export async function startIdentityRecoveryPairing(): Promise { + return invokeTauri("start_identity_recovery_pairing"); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1987e00ff1..abf74078da 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12763,9 +12763,20 @@ export function maybeInstallE2eTauriMocks() { } return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1"; } + case "start_identity_recovery_pairing": { + const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } + return `nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1&mode=recover`; + } case "cancel_pairing": case "confirm_pairing_sas": return null; + case "complete_identity_recovery_pairing": + mockIdentityLostCleared = true; + await emit("pairing-complete", {}); + return null; // ── NIP-IA identity archival ──────────────────────────────────────── // These mocks drive the archive-button gate matrix in // tests/e2e/identity-archive.spec.ts. Defaults keep the button hidden diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index 71c663ab4f..2ab41cb52d 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -52,7 +52,7 @@ test("normal first launch uses the already-persisted identity", async ({ test("lost boot opens onboarding gate directly on the key-import page", async ({ page, -}) => { +}, testInfo) => { await installMockBridge( page, { identityLost: true }, @@ -62,13 +62,44 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({ await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); + await page.waitForTimeout(1_000); + await page.screenshot({ + path: testInfo.outputPath("desktop-private-key-recovery.png"), + }); }); -test("importing a key from lost mode shows the relaunch-required screen", async ({ +test("lost boot keeps the pairing-code action stable while generating", async ({ page, }) => { + await installMockBridge( + page, + { identityLost: true, pairingStartDelayMs: 2_500 }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("nostr-import-phone-link").click(); + const copyButton = page.getByTestId("copy-identity-recovery-code"); + await expect(copyButton).toBeVisible(); + await expect(copyButton).toBeDisabled(); + await expect(copyButton).toHaveText("Generating pairing code..."); + const loadingButton = await copyButton.elementHandle(); + + await expect(copyButton).toBeEnabled(); + await expect(copyButton).toHaveText("Copy pairing code"); + expect( + await copyButton.evaluate( + (button, loading) => button === loading, + loadingButton, + ), + ).toBe(true); +}); + +test("lost boot offers phone recovery with a single-use QR", async ({ + page, +}, testInfo) => { await installMockBridge( page, { identityLost: true }, @@ -76,8 +107,254 @@ test("importing a key from lost mode shows the relaunch-required screen", async ); await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByText("Scan this code with a signed-in Buzz phone."), + ).toBeVisible(); + await expect( + page.getByText("On your phone, open Settings → Send identity to desktop."), + ).toBeVisible(); + await page.waitForTimeout(1_000); // Let the onboarding entrance motion settle. + await page.screenshot({ + path: testInfo.outputPath("desktop-phone-recovery-qr.png"), + fullPage: true, + }); + + const copyButton = page.getByTestId("copy-identity-recovery-code"); + await expect(copyButton).toHaveText("Copy pairing code"); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await copyButton.click(); + await expect(copyButton).toHaveText("Copied"); + + const copiedPayload = await page.evaluate(() => { + const log = ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: Record | null; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__; + return log?.findLast(({ command }) => command === "copy_text_to_clipboard") + ?.payload; + }); + expect(copiedPayload?.text).toMatch(/^nostrpair:\/\/.+&mode=recover$/); + + const commands = await page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>; + } + ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ); + expect( + commands.some( + (entry) => entry.command === "start_identity_recovery_pairing", + ), + ).toBe(true); +}); + +test("phone recovery uses the desktop pairing card semantics", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("nostr-import-phone-link").click(); + const card = page.getByTestId("identity-recovery-pairing"); + const qrContainer = card.getByTestId("identity-recovery-qr-container"); + const qrCode = card.getByTestId("identity-recovery-qr"); + const copyButton = card.getByTestId("copy-identity-recovery-code"); + await expect(qrCode).toBeVisible(); + await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57"); + await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3); + await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS( + "animation-name", + "buzz-qr-cell-reveal", + ); + const qrBox = await qrContainer.boundingBox(); + const copyBox = await copyButton.boundingBox(); + expect(qrBox).not.toBeNull(); + expect(copyBox).not.toBeNull(); + expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(1); + expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(1); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-sas-received", + payload: { sas: "123456" }, + }); + }); + + await expect( + card.getByText("Does this code match your phone?"), + ).toBeVisible(); + await expect( + page.getByText("Confirm the code before sharing your identity."), + ).toBeVisible(); + await expect( + card.getByText( + "This gives this desktop permanent access to your Buzz identity. Only continue if you trust it.", + ), + ).toBeVisible(); + await expect( + card.getByText(/On your phone, open Settings/), + ).not.toBeVisible(); + await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456"); + await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText( + "Codes match", + ); + await expect(card.getByTestId("deny-identity-recovery-sas")).toHaveText( + "Cancel", + ); + const cancelBox = await card + .getByTestId("deny-identity-recovery-sas") + .boundingBox(); + const confirmBox = await card + .getByTestId("confirm-identity-recovery-sas") + .boundingBox(); + expect(cancelBox).not.toBeNull(); + expect(confirmBox).not.toBeNull(); + expect((cancelBox?.y ?? 0) - (confirmBox?.y ?? 0)).toBeGreaterThan( + confirmBox?.height ?? 0, + ); +}); + +test("canceling recovery uses the standard pairing cancellation state", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-sas-received", + payload: { sas: "123456" }, + }); + }); + await page.getByTestId("deny-identity-recovery-sas").click(); + + await expect( + page.getByText("The codes didn't match. Pairing was canceled."), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + ({ command }) => command === "cancel_pairing", + ).length, + ), + ) + .toBeGreaterThan(0); +}); + +test("phone recovery continues to harness setup without creating or restarting", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.( + "complete_identity_recovery_pairing", + ); + }); + + await expect( + page.getByRole("heading", { name: "Set up your agent harnesses" }), + ).toBeVisible(); + await expect(page.getByTestId("relaunch-required")).toHaveCount(0); + await expect( + page.getByRole("heading", { + name: "Your unique identity key has been created", + }), + ).toHaveCount(0); +}); + +test("recovery turns relay failures into actionable copy", async ({ page }) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-error", + payload: { message: "failed to send sas-confirm" }, + }); + }); + + await expect( + page.getByText( + "This pairing code expired or lost its connection. Create a new code and try again.", + ), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); +}); + +test("desktop refreshes recovery codes before the relay expires them", async ({ + page, +}) => { + await page.clock.install(); + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + const recoveryStarts = () => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + ({ command }) => command === "start_identity_recovery_pairing", + ).length, + ); + await expect.poll(recoveryStarts).toBe(1); + + await page.clock.fastForward(90_000); + await expect.poll(recoveryStarts).toBe(2); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); +}); + +test("importing a key from lost mode shows the relaunch-required screen", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); @@ -97,9 +374,8 @@ test("start-new-identity from lost mode persists the ephemeral key after confirm { skipOnboardingSeed: true }, ); await page.goto("/"); - await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); page.on("dialog", (dialog) => dialog.accept()); @@ -131,9 +407,8 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as { skipOnboardingSeed: true }, ); await page.goto("/"); - await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); page.on("dialog", (dialog) => dialog.dismiss()); @@ -141,7 +416,7 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as // Still on the import screen — no navigation, no persist await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); await expect(page.getByTestId("relaunch-required")).toHaveCount(0); }); @@ -159,7 +434,7 @@ test("locked boot shows the keyring-locked screen without the onboarding gate or await expect(page.getByTestId("keyring-locked")).toBeVisible(); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toHaveCount(0); }); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 57e812091c..0f2e8ce617 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -620,6 +620,99 @@ test("completed users skip the loading gate while profile is still settling", as await expectHomeView(page); }); +test("fresh existing-identity path leads with private-key recovery", async ({ + page, +}) => { + await installMockBridge(page, undefined, { + skipCommunitySeed: true, + skipOnboardingSeed: true, + }); + await page.goto("/"); + + await page.getByRole("button", { name: "Use an existing key" }).click(); + await expect( + page.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + await expect( + page.getByText("Paste your private key to sign in to Buzz."), + ).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); + await expect(page.getByTestId("nostr-import-file-button")).toHaveText( + "backup file", + ); + await expect(page.getByTestId("nostr-import-phone-link")).toHaveText( + "recover from your phone", + ); + await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0); + + await page.getByTestId("nostr-import-file-button").click(); + const backupDialog = page.getByTestId("backup-recovery-dialog"); + await expect(backupDialog).toBeVisible(); + await expect( + backupDialog.getByRole("heading", { name: "Restore from a backup file" }), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), + ).toBeVisible(); + const unlockPreview = backupDialog.getByTestId("backup-file-unlock-preview"); + await expect(unlockPreview).toBeVisible(); + await expect(unlockPreview.locator("span")).toHaveCount(17); + await expect( + unlockPreview.getByTestId("backup-file-key-dots").locator("span"), + ).toHaveCount(9); + await expect( + unlockPreview.getByTestId("backup-file-unlock-preview-icon"), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-drop"), + ).toHaveCount(0); + await backupDialog + .getByTestId("nostr-import-backup-picker") + .evaluate((element) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(["backup"], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + }); + const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop"); + await expect(backupDrop).toHaveAttribute("data-dragging", "true"); + await expect(backupDrop).toContainText("Drop your backup file here"); + const [backupDropBox, backupFileSectionBox] = await Promise.all([ + backupDrop.boundingBox(), + unlockPreview.boundingBox(), + ]); + expect(backupDropBox?.width).toBeGreaterThan( + backupFileSectionBox?.width ?? 0, + ); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), + ).toBeVisible(); + await backupDrop.evaluate((element) => { + element.dispatchEvent( + new DragEvent("dragleave", { bubbles: true, cancelable: true }), + ); + }); + await expect(backupDrop).toHaveCount(0); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); + await backupDialog.getByRole("button", { name: "Close" }).click(); + + await page.getByTestId("nostr-import-phone-link").click(); + const phoneDialog = page.getByTestId("phone-recovery-dialog"); + await expect(phoneDialog).toBeVisible(); + await expect( + phoneDialog.getByRole("heading", { name: "Use your Buzz identity" }), + ).toBeVisible(); + await expect(phoneDialog.getByTestId("identity-recovery-qr")).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); +}); + test("first-launch key import continues to machine setup", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true, @@ -707,8 +800,10 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ // exactly the identity.ncryptsec our own save dialog produced. The accept // attribute is asserted explicitly because setInputFiles bypasses it — the // OS picker is what filters on it in real use. - await expect(page.getByTestId("nostr-import-file-button")).toBeVisible(); - const fileInput = page.getByTestId("nostr-import-file-input"); + await page.getByTestId("nostr-import-file-button").click(); + const fileInput = page + .getByTestId("backup-recovery-dialog") + .getByTestId("nostr-import-file-input"); await expect(fileInput).toHaveAttribute( "accept", ".key,.ncryptsec,text/plain", @@ -719,44 +814,87 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ mimeType: "text/plain", name: "not-a-backup.txt", }); - await expect(page.getByTestId("nostr-import-feedback")).toContainText( - /too large to be a key backup/i, - ); + await expect( + page + .getByTestId("backup-recovery-dialog") + .getByTestId("nostr-import-feedback"), + ).toContainText(/too large to be a key backup/i); // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; - await fileInput.setInputFiles({ - buffer: Buffer.from(`${mockNcryptsec}\n`), - mimeType: "text/plain", - name: "identity.ncryptsec", + // File contents advance to the password stage inside the same dialog. + const backupDialog = page.getByTestId("backup-recovery-dialog"); + const backupFileSection = backupDialog.getByTestId( + "nostr-import-backup-file-section", + ); + const backupFileSectionHeight = await backupFileSection.evaluate((element) => + Number.parseFloat(getComputedStyle(element).height), + ); + expect(backupFileSectionHeight).toBe(312); + const backupPicker = backupDialog.getByTestId("nostr-import-backup-picker"); + await backupPicker.evaluate((element) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(["backup"], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); }); + const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop"); + await expect(backupDrop).toBeVisible(); + await backupDrop.evaluate((element, contents) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File([contents], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + }, `${mockNcryptsec}\n`); - // File contents advance to the same focused password stage as manual input. await expect( - page.getByRole("heading", { name: "Unlock your account" }), + backupDialog.getByTestId("backup-password-timeline"), ).toBeVisible(); - await expect(page.getByTestId("backup-password-timeline")).toBeVisible(); - await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused(); + const passphraseSection = backupDialog.getByTestId( + "nostr-import-passphrase-section", + ); + await expect(passphraseSection).toBeVisible(); + const passphraseSectionHeight = await passphraseSection.evaluate((element) => + Number.parseFloat(getComputedStyle(element).height), + ); + expect(passphraseSectionHeight).toBe(backupFileSectionHeight); + await expect( + backupDialog.getByTestId("nostr-import-passphrase"), + ).toBeFocused(); - // Back first returns to key/file selection instead of leaving import. - await page.getByRole("button", { name: "Back", exact: true }).click(); + // Back first returns to backup-file selection instead of closing the dialog. + await backupDialog.getByRole("button", { name: "Back", exact: true }).click(); await expect( - page.getByRole("heading", { name: "Enter your private key" }), + backupDialog.getByRole("heading", { name: "Restore from a backup file" }), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), ).toBeVisible(); - await expect(page.getByTestId("nostr-import-card")).toBeVisible(); - await expect(page.getByTestId("nostr-import-file-button")).toBeVisible(); - await expect(page.getByTestId("nostr-import-nsec-input")).toHaveValue(""); await fileInput.setInputFiles({ buffer: Buffer.from(`${mockNcryptsec}\n`), mimeType: "text/plain", name: "identity.ncryptsec", }); - await page + await backupDialog .getByTestId("nostr-import-passphrase") .fill("mock horse battery staple lake orbit"); - await page.getByTestId("nostr-import-submit").click(); + await backupDialog.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 057594dfad..d5ae326afa 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -147,8 +147,11 @@ class App extends HookConsumerWidget { } } -Widget _buildSettingsPage(BuildContext context) => - const SettingsPage(profileHeader: SettingsProfileHeader()); +Widget _buildSettingsPage(BuildContext context) => SettingsPage( + profileHeader: const SettingsProfileHeader(), + identityRecoveryPageBuilder: (_) => + const PairingPage(addingCommunity: true, identityRecoveryOnly: true), +); class _SplashScreen extends StatelessWidget { const _SplashScreen(); diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 85b781052d..7061180b12 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -25,8 +25,13 @@ class PairingPage extends HookConsumerWidget { /// When true, the pairing page is being used to add a new community /// (user is already authenticated with at least one community). final bool addingCommunity; + final bool identityRecoveryOnly; - const PairingPage({super.key, this.addingCommunity = false}); + const PairingPage({ + super.key, + this.addingCommunity = false, + this.identityRecoveryOnly = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -51,6 +56,13 @@ class PairingPage extends HookConsumerWidget { Future handleScannerResult(String? code) async { if (code != null && context.mounted) { + if (identityRecoveryOnly && + Uri.tryParse(code)?.queryParameters['mode'] != 'recover') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Scan a desktop recovery code.')), + ); + return; + } await ref.read(pairingProvider.notifier).pair(code); } } @@ -91,7 +103,7 @@ class PairingPage extends HookConsumerWidget { onPressed: () => Navigator.of(context).pop(), ), title: Text( - 'Add Community', + identityRecoveryOnly ? 'Send to Desktop' : 'Add Community', style: isVerifyingSas ? null : context.textTheme.titleMedium?.copyWith( @@ -114,6 +126,7 @@ class PairingPage extends HookConsumerWidget { child: _SasVerificationView( sasCode: pairingState.sasCode ?? '------', confirmed: pairingState.userConfirmedSas, + sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop, onConfirm: () => ref.read(pairingProvider.notifier).confirmSas(), onDeny: () => ref.read(pairingProvider.notifier).denySas(), @@ -146,7 +159,7 @@ class PairingPage extends HookConsumerWidget { onConnect: () { final code = codeController.text.trim(); if (code.isNotEmpty) { - ref.read(pairingProvider.notifier).pair(code); + unawaited(handleScannerResult(code)); } }, ), @@ -182,12 +195,14 @@ class PairingPage extends HookConsumerWidget { class _SasVerificationView extends StatelessWidget { final String sasCode; final bool confirmed; + final bool sendsIdentityToDesktop; final VoidCallback onConfirm; final VoidCallback onDeny; const _SasVerificationView({ required this.sasCode, required this.confirmed, + required this.sendsIdentityToDesktop, required this.onConfirm, required this.onDeny, }); @@ -242,7 +257,9 @@ class _SasVerificationView extends StatelessWidget { const SizedBox(height: Grid.lg), Text( - 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.', + sendsIdentityToDesktop + ? 'This sends your full Buzz identity to the desktop\nand grants it permanent access. Only confirm a\ndesktop you trust and a recovery you started.' + : 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.', textAlign: TextAlign.center, style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 6a6c57a673..5adde987eb 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -36,12 +36,14 @@ class PairingState { final String? errorMessage; final String? sasCode; final bool userConfirmedSas; + final bool sendsIdentityToDesktop; const PairingState({ this.status = PairingStatus.idle, this.errorMessage, this.sasCode, this.userConfirmedSas = false, + this.sendsIdentityToDesktop = false, }); PairingState copyWith({ @@ -49,11 +51,14 @@ class PairingState { String? errorMessage, String? sasCode, bool? userConfirmedSas, + bool? sendsIdentityToDesktop, }) => PairingState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, sasCode: sasCode ?? this.sasCode, userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas, + sendsIdentityToDesktop: + sendsIdentityToDesktop ?? this.sendsIdentityToDesktop, ); } @@ -111,10 +116,14 @@ class PairingNotifier extends Notifier { // transition immediately and process any buffered payload. if (_sasConfirmReceived) { state = state.copyWith(status: PairingStatus.transferring); - final pending = _pendingPayload; - if (pending != null) { - _pendingPayload = null; - _handlePayload(pending); + if (_sendIdentityToSource) { + _sendIdentityPayload(); + } else { + final pending = _pendingPayload; + if (pending != null) { + _pendingPayload = null; + _handlePayload(pending); + } } return; } @@ -149,6 +158,7 @@ class PairingNotifier extends Notifier { _sasConfirmReceived = false; _userConfirmedSas = false; _pendingPayload = null; + _sendIdentityToSource = false; } // ── NIP-AB pairing flow ───────────────────────────────────────────────── @@ -163,6 +173,7 @@ class PairingNotifier extends Notifier { Uint8List? _conversationKey; bool _sasConfirmReceived = false; bool _userConfirmedSas = false; + bool _sendIdentityToSource = false; Map? _pendingPayload; // buffered until user confirms SAS final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling @@ -174,6 +185,8 @@ class PairingNotifier extends Notifier { final qr = parseNostrpairUri(uri); _sourcePubkey = qr.sourcePubkey; _sessionSecret = qr.sessionSecret; + _sendIdentityToSource = + Uri.parse(uri).queryParameters['mode'] == 'recover'; final relayWsUrl = qr.relays.first; @@ -234,6 +247,7 @@ class PairingNotifier extends Notifier { state = PairingState( status: PairingStatus.confirmingSas, sasCode: formatSas(sasCode), + sendsIdentityToDesktop: _sendIdentityToSource, ); // 9. Start 120s session timeout. @@ -359,6 +373,9 @@ class PairingNotifier extends Notifier { case 'abort': _handleAbort(msg); _processedEventIds.add(eventId); + case 'complete': + _handleComplete(msg); + _processedEventIds.add(eventId); } } catch (e) { // Silently discard invalid events per NIP-AB §Event Validation. @@ -400,15 +417,44 @@ class PairingNotifier extends Notifier { if (_userConfirmedSas) { _userConfirmedSas = false; state = state.copyWith(status: PairingStatus.transferring); - final pending = _pendingPayload; - if (pending != null) { - _pendingPayload = null; - _handlePayload(pending); + if (_sendIdentityToSource) { + _sendIdentityPayload(); + } else { + final pending = _pendingPayload; + if (pending != null) { + _pendingPayload = null; + _handlePayload(pending); + } } } // Otherwise stay in confirmingSas — user must still confirm via confirmSas(). } + void _sendIdentityPayload() { + final nsec = ref.read(relayConfigProvider).nsec; + if (nsec == null || nsec.isEmpty) { + _sendAbort('protocol_error'); + _cleanup(); + state = const PairingState( + status: PairingStatus.error, + errorMessage: 'No identity is available on this phone.', + ); + return; + } + final content = _encryptMessage({ + 'type': 'payload', + 'payload_type': 'nsec', + 'payload': nsec, + }); + _publishEvent( + kind: 24134, + content: content, + tags: [ + ['p', _sourcePubkey!], + ], + ); + } + void _handlePayload(Map msg) { // Only accept payload after the transcript hash was verified. if (!_sasConfirmReceived) return; @@ -436,6 +482,22 @@ class PairingNotifier extends Notifier { _processPayload(payloadType, payload); } + void _handleComplete(Map msg) { + if (!_sendIdentityToSource || state.status != PairingStatus.transferring) { + return; + } + if (msg['success'] != true) { + _cleanup(); + state = const PairingState( + status: PairingStatus.error, + errorMessage: 'Desktop could not store the identity.', + ); + return; + } + _cleanup(); + state = const PairingState(status: PairingStatus.success); + } + void _handleAbort(Map msg) { final reason = msg['reason'] as String? ?? 'unknown'; _cleanup(); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index d53f39a672..066dd1fd3d 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -24,9 +24,14 @@ part 'settings_page/appearance_section.dart'; part 'settings_page/connection_section.dart'; class SettingsPage extends HookConsumerWidget { - const SettingsPage({super.key, required this.profileHeader}); + const SettingsPage({ + super.key, + required this.profileHeader, + required this.identityRecoveryPageBuilder, + }); final Widget profileHeader; + final WidgetBuilder identityRecoveryPageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -67,7 +72,9 @@ class SettingsPage extends HookConsumerWidget { children: [ profileHeader, const _AppearanceSection(), - const _ConnectionSection(), + _ConnectionSection( + identityRecoveryPageBuilder: identityRecoveryPageBuilder, + ), const _RemoveCommunitySection(), ], ), diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 19da784a63..631f870abc 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -1,7 +1,9 @@ part of '../settings_page.dart'; class _ConnectionSection extends ConsumerWidget { - const _ConnectionSection(); + const _ConnectionSection({required this.identityRecoveryPageBuilder}); + + final WidgetBuilder identityRecoveryPageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -16,7 +18,18 @@ class _ConnectionSection extends ConsumerWidget { title: 'Connected to', subtitle: config.baseUrl, ), - if (nsec != null && nsec.isNotEmpty) _IdentityRow(nsec: nsec), + if (nsec != null && nsec.isNotEmpty) ...[ + _IdentityRow(nsec: nsec), + AppListRow( + icon: LucideIcons.scanQrCode, + title: 'Send identity to desktop', + subtitle: 'Scan a recovery code shown by Buzz Desktop', + trailing: const _RowChevron(), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: identityRecoveryPageBuilder), + ), + ), + ], ], ); } diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 678be9dfe7..e8f34a6f71 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -179,6 +179,69 @@ void main() { expect(scanButton.onPressed, isNull); expect(pairingCodeButton.onPressed, isNull); }); + + testWidgets('recovery entry rejects ordinary nostrpair codes', ( + tester, + ) async { + final notifier = _RecordingPairingNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [pairingProvider.overrideWith(() => notifier)], + child: const PairingPage( + addingCommunity: true, + identityRecoveryOnly: true, + ), + ), + ); + + await _expandPairingCode(tester); + await tester.enterText(find.byType(TextField), 'nostrpair://ordinary'); + await tester.tap(find.text('Connect')); + await tester.pump(); + + expect(find.text('Scan a desktop recovery code.'), findsOneWidget); + expect(notifier.pairedCodes, isEmpty); + }); + + testWidgets('recovery entry accepts mode=recover codes', (tester) async { + final notifier = _RecordingPairingNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [pairingProvider.overrideWith(() => notifier)], + child: const PairingPage( + addingCommunity: true, + identityRecoveryOnly: true, + ), + ), + ); + + await _expandPairingCode(tester); + const code = 'nostrpair://desktop?mode=recover'; + await tester.enterText(find.byType(TextField), code); + await tester.tap(find.text('Connect')); + await tester.pump(); + + expect(notifier.pairedCodes, [code]); + }); + + testWidgets('recovery SAS warns about permanent desktop access', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith( + () => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true), + ), + ], + child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), + ), + ); + + expect(find.textContaining('full Buzz identity'), findsOneWidget); + expect(find.textContaining('permanent access'), findsOneWidget); + expect(find.text('Codes Match'), findsOneWidget); + }); }); } @@ -227,12 +290,37 @@ class _ConnectingPairingNotifier extends Notifier void denySas() {} } +class _RecordingPairingNotifier extends Notifier + implements PairingNotifier { + final pairedCodes = []; + + @override + PairingState build() => const PairingState(); + + @override + Future pair(String rawInput) async => pairedCodes.add(rawInput); + + @override + void reset() {} + + @override + void confirmSas() {} + + @override + void denySas() {} +} + class _ConfirmingSasPairingNotifier extends Notifier implements PairingNotifier { + _ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false}); + + final bool sendsIdentityToDesktop; + @override - PairingState build() => const PairingState( + PairingState build() => PairingState( status: PairingStatus.confirmingSas, sasCode: '123456', + sendsIdentityToDesktop: sendsIdentityToDesktop, ); @override diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 6f49f71921..c14599bbef 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -2,9 +2,14 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/pairing/pairing_crypto.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/features/pairing/pairing_socket.dart'; import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/crypto/ecdh.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/relay/relay.dart'; /// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and /// SSRF-prevention validation. @@ -180,6 +185,115 @@ void main() { container.read(pairingProvider.notifier).reset(); expect(container.read(pairingProvider).status, PairingStatus.idle); }); + + group('desktop identity recovery', () { + const sourceSecret = + '09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506'; + const sessionSecretHex = + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; + late _ControllableSocket socket; + late PairingNotifier notifier; + late String recoveryCode; + + setUp(() { + final source = nostr.Keys(sourceSecret); + recoveryCode = + 'nostrpair://${source.public}' + '?secret=$sessionSecretHex' + '&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1&mode=recover'; + notifier = PairingNotifier( + socketFactory: + ({ + required wsUrl, + required ephemeralPrivkey, + required onMessage, + required onDisconnected, + }) { + socket = _ControllableSocket( + ephemeralPrivkey: ephemeralPrivkey, + onMessage: onMessage, + onDisconnected: onDisconnected, + ); + return socket; + }, + ); + container = ProviderContainer( + overrides: [ + pairingProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(_RecoveryRelayConfig.new), + ], + ); + container.read(pairingProvider); + notifier = container.read(pairingProvider.notifier); + }); + + test('recovery URI enables phone-to-desktop transfer', () async { + await notifier.pair(recoveryCode); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.confirmingSas); + expect(state.sendsIdentityToDesktop, isTrue); + expect(state.sasCode, hasLength(6)); + }); + + test( + 'matching SAS sends nsec and successful completion finishes', + () async { + await notifier.pair(recoveryCode); + notifier.confirmSas(); + expect(container.read(pairingProvider).userConfirmedSas, isTrue); + + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + + expect( + container.read(pairingProvider).status, + PairingStatus.transferring, + ); + final sentMessages = socket.decryptedPublishedMessages(sourceSecret); + expect( + sentMessages.any( + (message) => + message['type'] == 'payload' && + message['payload_type'] == 'nsec' && + message['payload'] == _RecoveryRelayConfig.nsec, + ), + isTrue, + ); + + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'complete', 'success': true}, + ); + expect(container.read(pairingProvider).status, PairingStatus.success); + }, + ); + + test('desktop storage failure surfaces an error', () async { + await notifier.pair(recoveryCode); + notifier.confirmSas(); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'complete', 'success': false}, + ); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('could not store')); + }); + }); }); } @@ -241,3 +355,92 @@ class _DisconnectingSocket extends PairingSocket { disconnectCallback(Exception('Connection closed')); } } + +class _RecoveryRelayConfig extends RelayConfigNotifier { + static final nsec = nostr.Keys( + '1111111111111111111111111111111111111111111111111111111111111111', + ).nsec; + + @override + RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec); +} + +class _ControllableSocket extends PairingSocket { + final String ephemeralPrivkey; + final void Function(List message) relayMessageCallback; + final List> published = []; + bool _connected = false; + int _eventSequence = 0; + + _ControllableSocket({ + required this.ephemeralPrivkey, + required super.onMessage, + required super.onDisconnected, + }) : relayMessageCallback = onMessage, + super(wsUrl: 'ws://unused', ephemeralPrivkey: ephemeralPrivkey); + + @override + bool get isConnected => _connected; + + @override + Future connect() async => _connected = true; + + @override + void subscribe(String subId, int kind, String pubkeyHex) {} + + @override + void publishEvent(Map event) => published.add(event); + + @override + void dispose() => _connected = false; + + List> decryptedPublishedMessages(String sourceSecret) { + final key = getConversationKey( + sourceSecret, + nostr.Keys(ephemeralPrivkey).public, + ); + return published + .map( + (event) => + jsonDecode(nip44Decrypt(key, event['content'] as String)) + as Map, + ) + .toList(); + } + + void sendSourceMessage({ + required String sourceSecret, + required String sessionSecretHex, + required Map message, + bool includeTranscriptHash = false, + }) { + final source = nostr.Keys(sourceSecret); + final targetPubkey = nostr.Keys(ephemeralPrivkey).public; + final sessionSecret = hexToBytes(sessionSecretHex); + final body = Map.from(message); + if (includeTranscriptHash) { + final shared = ecdhSharedSecret(sourceSecret, targetPubkey); + final (_, sasInput) = deriveSas(shared, sessionSecret); + body['transcript_hash'] = bytesToHex( + deriveTranscriptHash( + deriveSessionId(sessionSecret), + hexToBytes(source.public), + hexToBytes(targetPubkey), + sasInput, + sessionSecret, + ), + ); + } + final key = getConversationKey(sourceSecret, targetPubkey); + final event = nostr.Event.from( + kind: 24134, + content: nip44Encrypt(key, jsonEncode(body)), + tags: [ + ['p', targetPubkey], + ], + secretKey: sourceSecret, + createdAt: 1_700_000_000 + _eventSequence++, + ); + relayMessageCallback(['EVENT', 'pair', event.toMap()]); + } +} diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 010db98ba3..6b166c8efa 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -172,7 +172,10 @@ void main() { testWidgets('settings hides accent navigation for Buzz', (tester) async { await _pumpPicker( tester, - const SettingsPage(profileHeader: SizedBox.shrink()), + SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4}, ); @@ -184,7 +187,10 @@ void main() { ) async { await _pumpPicker( tester, - const SettingsPage(profileHeader: SizedBox.shrink()), + SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), prefs: { 'buzz_theme_mode': 'light', 'buzz_color_scheme': 'github-light', From c777d4fb9af4c3f66009ee3216650d9ea30310d7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 6 Aug 2026 16:57:12 -0400 Subject: [PATCH 11/16] chore(hooks): run desktop typecheck in pre-push (#5110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local pre-push gate ran biome (`desktop-check`) and node:test (`desktop-test`) for desktop changes but never `tsc`, so TypeScript errors surface no earlier than CI's `desktop-core` job (`just desktop-build` = `tsc && vite build`). A branch with type errors passes every local hook today. This adds a `desktop-typecheck` pre-push command running `just desktop-typecheck` (`tsc --noEmit`) with the same glob/exclude as `desktop-check`, and updates the hook documentation in `AGENTS.md`. CI is unchanged — it already typechecks via `desktop-build`. Signed-off-by: Will Pfleger --- AGENTS.md | 9 +++++---- lefthook.yml | 11 ++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 571871c3a4..2d3939bbb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,10 +100,11 @@ Run `just test` for integration tests if you touched `buzz-relay`, formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format). Auto-fixable issues are fixed and re-staged; unfixable lint issues block the -commit. **Pre-push hooks** run clippy (workspace + Tauri) and fast unit tests -in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with -pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix all formatting -in one shot. Run `just ci` for the full local gate. Run `just hooks` to +commit. **Pre-push hooks** run clippy (workspace + Tauri), desktop TypeScript +typechecking (`tsc --noEmit`), and fast unit tests in parallel (Rust, desktop +JS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are +CI-only. Run `just fix-all` to auto-fix all formatting in one shot. Run +`just ci` for the full local gate. Run `just hooks` to re-install hooks after env changes. Before agents run Git or hooks, activate the repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook commands to compensate for an unconfigured shell `PATH`. diff --git a/lefthook.yml b/lefthook.yml index 75d205722f..5b992f19af 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,9 +2,10 @@ # .github/workflows/ci.yml — keep the two in sync. Deliberate deviations: # - The `.github/workflows/ci.yml` path CI adds to its `rust`/`mobile` filters # is omitted; a CI-workflow-only edit doesn't need a local test run. -# - `desktop-check`/`desktop-test` don't trigger on `rust` changes, though CI's -# Desktop Core job does. Those commands are pure TS (biome + node:test) with -# no Rust dependency, so the extra trigger would be spurious locally. +# - `desktop-check`/`desktop-typecheck`/`desktop-test` don't trigger on `rust` +# changes, though CI's Desktop Core job does. Those commands are pure TS +# (biome + tsc + node:test) with no Rust dependency, so the extra trigger +# would be spurious locally. # - Deletion-only surface changes do not trigger local hooks: lefthook 2.1.x # drops deleted paths from push-file discovery (`extractFiles` existence # check, repository.go). CI's dorny/paths-filter catches deletions. @@ -57,6 +58,10 @@ pre-push: glob: ["desktop/**", "pnpm-lock.yaml"] exclude: ["desktop/src-tauri/**"] run: just desktop-check + desktop-typecheck: + glob: ["desktop/**", "pnpm-lock.yaml"] + exclude: ["desktop/src-tauri/**"] + run: just desktop-typecheck desktop-test: glob: ["desktop/**", "pnpm-lock.yaml"] exclude: ["desktop/src-tauri/**"] From b08c8b126cee8de424eb0c03af22a45ff9a1e8a7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 6 Aug 2026 17:21:02 -0400 Subject: [PATCH 12/16] fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the bug where running a dev build with stale localStorage would publish outdated channel sections, sort preferences, starred channels, and muted channels to the relay, clobbering the DMG installation's live state. ## Root cause All four sidebar-preference sync managers (`channelSectionsSync`, `channelSortSync`, `channelStarsSync`, `channelMutesSync`) collapsed five distinct fetch outcomes — no event, timeout, error, auth-race empty result, decrypt/parse failure — into a single `null`. Each hook's boot effect treated `null` as "no remote exists" and seed-published whatever was in localStorage, stamped at `max(now, lastRemoteCreatedAt+1)` with `lastRemoteCreatedAt` reset to 0 on every boot. A dev build with stale localStorage therefore re-signed old state as newer, and the DMG's live subscription applied it. ## Two guards **1. Tri-state fetch result** (`found | absent | failed`) — decrypt failure on an existing event reports `failed` and records `event.created_at`, so seed-publish is blocked even when the payload is unreadable. **2. Persisted head watermark** (`sidebarSyncWatermark.ts`) — keyed `{blobType, pubkey, normalizedRelayUrl}`, written to localStorage on every observed remote event (before decrypt on all paths: initial fetch, live subscription, `fetchOwnBlobBeforePublish`), hydrated at construction. Any session that has ever seen a remote blob skips seed-publish on the next boot even when the fetch returns empty. Relay URLs are normalised via `shared/lib/normalizeRelayUrl` (also used by profile storage) so the same relay written two ways never produces two keys. **Bootstrap owns the seed.** Each manager exposes `bootstrap(localStore)` that fetches, records the raw head, and delegates the decision to the single `runBootstrap` policy: hold on `failed` or `absent + prior watermark`, seed on genuine first-sync (`absent + zero watermark + non-empty local`), `apply-remote` when a blob was found. Hooks only act on `apply-remote`; they cannot publish during bootstrap. First-time sync is unchanged: successful EOSE with no event, zero watermark, and non-empty local state still seeds. ## LWW baseline preservation `fetchOwnBlobBeforePublish` for sections/sort snapshots the watermark before `recordRemoteHead` advances it, then compares the fetched event against the snapshot — advancing first would make `remote.createdAt > lastRemoteCreatedAt` always false and silently kill the whole-blob LWW merge. Stars/mutes merge per-entry via `mergeStores`, so no snapshot is needed there. ## Relay lifecycle All four hooks require a defined `relayUrl` (plumbed from `communitiesHook.activeCommunity?.relayUrl` in `AppShell.tsx`); while it is undefined no manager is constructed and no boot/live/reconnect effect binds. All effects depend on `[pubkey, relayUrl]`, so community switches tear down and rebind. `destroy()` cancels pending publishes without flushing — flushing would race community switching and could publish relay A's state to relay B via the shared `relayClient` singleton. Pending debounce-window edits are intentionally dropped: stars/mutes entries survive via per-entry merge on the next publish; a dropped sections/sort edit is lost because bootstrap whole-blob-replaces from remote on return. Known trade-off: a first boot with the relay unreachable holds (never seeds) until the user's next explicit edit — preferred over risking a stale seed-publish. ## Files - `sidebarSyncWatermark.ts` — watermark persistence + `runBootstrap` policy (tri-state `FetchResult`, `readWatermark`, `advanceWatermark`) - `shared/lib/normalizeRelayUrl.ts` — relay-URL normalisation shared by watermark keys and profile storage - `channelSectionsSync.ts`, `channelSortSync.ts`, `channelStarsSync.ts`, `channelMutesSync.ts` — tri-state fetch, pre-decrypt `recordRemoteHead` on all paths, sections/sort watermark snapshot for LWW, `bootstrap()`, cancel-without-flush `destroy()` - `useChannelSections.ts`, `useChannelSortPreference.ts`, `useChannelStars.ts`, `useChannelMutes.ts` — act on `bootstrap()` results, gate on `relayUrl`, `[pubkey, relayUrl]` deps on all effects - `AppShell.tsx` — passes `activeCommunity?.relayUrl` to `useChannelMutes` and `useChannelStars` - `sidebarSyncTestHelpers.mjs` — shared fake-window/localStorage/Tauri mocks for the four manager suites - Test suites — mutation-sensitive coverage: `failed→hold`, `absent+watermark→hold`, first-sync seeds, undecryptable head recorded on all paths, relay-A/B watermark isolation, watermark restart round-trip, sections/sort LWW baseline --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- desktop/src/app/AppShell.tsx | 2 + .../profile/lib/selfProfileStorage.ts | 12 +- .../sidebar/lib/channelMutesSync.test.mjs | 198 ++++++++++ .../features/sidebar/lib/channelMutesSync.ts | 107 +++-- .../sidebar/lib/channelSectionsStorage.ts | 2 +- .../sidebar/lib/channelSectionsSync.test.mjs | 364 +++++++++++------- .../sidebar/lib/channelSectionsSync.ts | 104 +++-- .../sidebar/lib/channelSortPreference.ts | 2 +- .../sidebar/lib/channelSortSync.test.mjs | 326 ++++++++++------ .../features/sidebar/lib/channelSortSync.ts | 98 +++-- .../sidebar/lib/channelStarsSync.test.mjs | 202 ++++++++++ .../features/sidebar/lib/channelStarsSync.ts | 107 +++-- .../sidebar/lib/sidebarSyncTestHelpers.mjs | 85 ++++ .../sidebar/lib/sidebarSyncWatermark.test.mjs | 253 ++++++++++++ .../sidebar/lib/sidebarSyncWatermark.ts | 135 +++++++ .../features/sidebar/lib/useChannelMutes.ts | 40 +- .../sidebar/lib/useChannelSections.ts | 26 +- .../sidebar/lib/useChannelSortPreference.ts | 25 +- .../features/sidebar/lib/useChannelStars.ts | 40 +- desktop/src/shared/lib/normalizeRelayUrl.ts | 8 + 20 files changed, 1665 insertions(+), 471 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/channelMutesSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelStarsSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts create mode 100644 desktop/src/shared/lib/normalizeRelayUrl.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f765b843b3..147ab57381 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -173,9 +173,11 @@ export function AppShell() { const identityQuery = useIdentityQuery(); const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); usePersonaSync( identityQuery.data?.pubkey, diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index dbc4f88760..02e083ae1d 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -11,16 +11,10 @@ * prevents one community's cached identity from bleeding into another. */ -const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; +export { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; -/** - * Normalizes a relay URL for use in storage keys. - * Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to - * the same key regardless of formatting differences. - */ -export function normalizeRelayUrl(relayUrl: string): string { - return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); -} +const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; /** * Dispatched on window after a successful writeSelfProfileCache so that any diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs new file mode 100644 index 0000000000..845e5a5acc --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; +import { + makeFakeWindow, + installFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the community-switch cross-relay publish vector: +// mute a channel in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. +test("destroy: cancels pending publish without flushing to the relay", () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-test", RELAY); + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); + manager.destroy(); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-race", RELAY); + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: is safe to call with no pending publish", () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-fresh", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-fresh:${RELAY_KEY}`, + ), + null, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingMuteStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. relay-A / relay-B watermark isolation +// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. +test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + const managerB = new ChannelMuteSyncManager("pk-iso", relayB); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayB)}`, + ), + null, + "relay B watermark must be independent of relay A head", + ); + const result = await managerB.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok( + managerB.getPendingMuteStore() !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 0a0d2bb9f6..5e8a17e74d 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -11,8 +11,15 @@ import { parseMutePayload, type ChannelMuteStore, } from "./channelMutesStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-mutes"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteMutes = { @@ -34,16 +41,20 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelMuteSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelMuteStore | null = null; private lastPublishedStore: ChannelMuteStore | null = null; + private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteMutes(): Promise { + async fetchRemoteMutes(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_MUTES], @@ -51,19 +62,31 @@ export class ChannelMuteSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingMutePublish(): void { @@ -99,12 +122,11 @@ export class ChannelMuteSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Record the raw head before decrypt on the pre-publish path too. + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); return mergeStores(store, remote.store); } catch { return store; @@ -132,6 +154,10 @@ export class ChannelMuteSyncManager { private async doPublish(store: ChannelMuteStore): Promise { try { const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (community switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { this.pendingStore = null; return; @@ -154,15 +180,13 @@ export class ChannelMuteSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); + if (this.destroyed) return; await relayClient.publishEvent( event, "Timed out publishing channel mutes.", "Failed to publish channel mutes.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -182,12 +206,11 @@ export class ChannelMuteSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -195,14 +218,30 @@ export class ChannelMuteSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelMuteStore) { + const fetchResult = await this.fetchRemoteMutes(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, + publishFn: (s) => this.publishMutes(s), + }); + } + destroy(): void { - if (this.debounceTimer !== null && this.pendingStore !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - void this.doPublish(this.pendingStore); - } else if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's state to relay B via the shared relayClient + // singleton. Local entries survive because the apply/publish paths merge + // per-entry via mergeStores, so no local work is permanently lost. + this.destroyed = true; + this.cancelPendingMutePublish(); + this.pendingStore = null; } } diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 0d6b5768b6..3900c40c18 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -1,4 +1,4 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1"; diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 5dad6c8673..904ac1f3f2 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -3,6 +3,11 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; +import { + makeFakeWindow, + installFakeWindow, + installTauriMock, +} from "./sidebarSyncTestHelpers.mjs"; function makeStore(overrides = {}) { return { @@ -13,198 +18,265 @@ function makeStore(overrides = {}) { }; } +function makeSectionsStore(sections = []) { + return { version: 1, sections, assignments: {} }; +} + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + // ─── destroy() must cancel pending publish, not flush ───────────────────────── // Regression guard for the community-switch cross-relay publish vector: // edit sections in relay A → destroy() is called (relayUrl dep change) → -// no publish should fire. The scoped localStorage write is durable; when the -// user returns to relay A the seed-publish path handles it. +// no publish should fire. test("destroy: cancels pending publish without flushing to the relay", () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - // Simulate the timer scheduler with a manual clock so we can advance it. - let timerCallback = null; - const originalSetTimeout = globalThis.window?.setTimeout; - const originalClearTimeout = globalThis.window?.clearTimeout; - - // Inject a fake window.setTimeout/clearTimeout if needed. - const fakeTimers = []; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - globalThis.window.setTimeout = (fn, _ms) => { - const id = nextId++; - fakeTimers.push({ id, fn }); - timerCallback = fn; - return id; - }; - globalThis.window.clearTimeout = (id) => { - const idx = fakeTimers.findIndex((t) => t.id === id); - if (idx !== -1) { - fakeTimers.splice(idx, 1); - timerCallback = null; - } - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-test"); - const store = makeStore({ - sections: [{ id: "s1", name: "Work", order: 0 }], - }); - - // Queue a publish — this sets the debounce timer. - manager.publishSections(store); - assert.ok(timerCallback !== null, "debounce timer should be set"); - - // Destroy before the debounce fires — simulates community switch. - manager.destroy(); - - // Timer must be cleared and no publish should fire now. - assert.ok( - timerCallback === null, - "debounce timer should be cleared on destroy", - ); - - // Advance time by invoking the callback that was cleared — it shouldn't exist. - // If clearTimeout didn't work, try firing whatever was captured before destroy. - // (There's nothing to fire after a correct destroy.) - assert.equal( - publishCalls.length, - 0, - "no publish event should have been sent after destroy", + const manager = new ChannelSectionSyncManager("pk-test", RELAY); + manager.publishSections( + makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), ); + assert.ok(fw._hasTimer(), "debounce timer should be set"); + manager.destroy(); + assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingStore(), null); } finally { - // Restore timer functions. - if (originalSetTimeout !== undefined) { - globalThis.window.setTimeout = originalSetTimeout; - } - if (originalClearTimeout !== undefined) { - globalThis.window.clearTimeout = originalClearTimeout; - } + restore(); mock.reset(); } }); -// Regression guard for the timer-fired race: debounce fires → doPublish starts -// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep -// change) → publishEvent must never be called even though the timer already -// fired and cleared itself before destroy() ran. +// Regression guard for the timer-fired race: debounce fires → doPublish awaits +// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - // fetchEvents is held until we release it — simulates the latency window. let releaseFetch = null; const publishCalls = []; - - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - // resolve with empty so fetchOwnBlobBeforePublish returns the local store - releaseFetch = () => resolve([]); - }); - }); + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - let capturedCallback = null; - let nextId = 1; - const origSetTimeout = globalThis.window.setTimeout; - const origClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - capturedCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - capturedCallback = null; - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-race"); - const store = makeStore({ - sections: [{ id: "s1", name: "Work", order: 0 }], - }); - - // Queue the publish — captures the debounce callback. - manager.publishSections(store); - assert.ok(capturedCallback !== null, "debounce timer should be set"); - - // Fire the debounce manually — this starts doPublish() and nulls - // debounceTimer inside publishSections' callback, leaving the async - // doPublish running and awaiting fetchOwnBlobBeforePublish. - const timerFn = capturedCallback; - capturedCallback = null; // timer cleared itself inside the callback - timerFn(); - - // Now destroy() — debounceTimer is already null (timer fired), so only - // the destroyed flag can stop doPublish. + const manager = new ChannelSectionSyncManager("pk-race", RELAY); + manager.publishSections( + makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), + ); + fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish manager.destroy(); - - // Release the held fetchEvents — fetchOwnBlobBeforePublish resolves with - // the local store, then doPublish should check destroyed and abort. releaseFetch(); - - // Drain microtasks so doPublish fully runs through to its abort point. - await new Promise((resolve) => setTimeout(resolve, 0)); - + await new Promise((r) => setTimeout(r, 0)); assert.equal( publishCalls.length, 0, - "publishEvent must not be called after destroy() even when timer already fired", + "publishEvent must not fire after destroy", ); } finally { - globalThis.window.setTimeout = origSetTimeout; - globalThis.window.clearTimeout = origClearTimeout; + restore(); mock.reset(); } }); test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSectionSyncManager("pk-no-pending"); - // Should not throw even with nothing queued. - assert.doesNotThrow(() => manager.destroy()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// Wiring tests 1-3 drive the production bootstrap() path; policy tested once +// in sidebarSyncWatermark.test.mjs. + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); + } }); -test("destroy: cancelPendingPublish clears pendingStore", () => { - let timerCallback = null; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); } - const orig = globalThis.window.setTimeout; - const origClear = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - timerCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - timerCallback = null; - }; +}); +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-pending-null"); - const store = makeStore({ - sections: [{ id: "s1", name: "Test", order: 0 }], - }); - manager.publishSections(store); - assert.deepEqual(manager.getPendingStore(), store); + const manager = new ChannelSectionSyncManager("pk-fresh", RELAY); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); - manager.destroy(); +// 4. LWW baseline: newer decryptable pre-publish event still wins after an +// undecryptable head was recorded. +// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison +// 200>200=false → local wins instead of remote → wrong content encrypted. +test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + const REMOTE_ID = "remote-section-from-relay"; + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock( + JSON.stringify({ + version: 1, + sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }], + assignments: {}, + }), + ); + try { + const manager = new ChannelSectionSyncManager("pk-lww", RELAY); + await manager.fetchRemoteSections(); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`, + ) ?? "0", + ) >= 100, + ); + manager.publishSections( + makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]), + ); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + const pt = tauri.capturedPlaintext(); + assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); + assert.ok( + JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID), + `remote sections must win LWW merge — got: ${pt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 5. live-sub: undecryptable event on live path records head before decrypt +// Mutation test: removing recordRemoteHead before decrypt in the live callback +// leaves watermark at 0 after a live event. +test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-live", RELAY); assert.equal( - manager.getPendingStore(), + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, + ), null, - "pendingStore must be null after destroy", + "watermark starts absent", + ); + await manager.subscribeToSections(() => {}); + assert.ok( + liveCallback !== null, + "subscribeLive must have captured the callback", + ); + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); + await new Promise((r) => setTimeout(r, 0)); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", ); - assert.ok(timerCallback === null, "timer must be cleared after destroy"); } finally { - globalThis.window.setTimeout = orig; - globalThis.window.clearTimeout = origClear; + restore(); + mock.reset(); } }); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 70930c26f6..858b62430f 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -11,8 +11,15 @@ import { type ChannelSection, type ChannelSectionStore, } from "./channelSectionsStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-sections"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteSections = { @@ -36,17 +43,22 @@ async function decryptAndParse( export class ChannelSectionSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelSectionStore | null = null; private lastPublishedStore: ChannelSectionStore | null = null; private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + // Hydrate from localStorage so we never seed-publish if a remote blob has + // been seen in a prior session. + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteSections(): Promise { + async fetchRemoteSections(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SECTIONS], @@ -54,21 +66,37 @@ export class ChannelSectionSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + // An event exists — record its created_at regardless of whether we can + // decrypt it, so seed-publish is blocked even when the payload is + // unreadable (e.g. wrong key). + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; } } + /** Update in-memory + persisted watermark. */ + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -102,11 +130,17 @@ export class ChannelSectionSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Snapshot the watermark before advancing it: after recordRemoteHead + // runs, lastRemoteCreatedAt equals event.created_at, so the LWW + // comparison remote.createdAt > lastRemoteCreatedAt would always be + // false and silently suppress the merge. + const headBeforeFetch = this.lastRemoteCreatedAt; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; // Sections use whole-blob LWW: take whichever is newer - if (remote.createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = remote.createdAt; + if (remote.createdAt > headBeforeFetch) { return remote.store; } return store; @@ -181,10 +215,7 @@ export class ChannelSectionSyncManager { "Timed out publishing channel sections.", "Failed to publish channel sections.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -204,12 +235,11 @@ export class ChannelSectionSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -217,14 +247,28 @@ export class ChannelSectionSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelSectionStore) { + const fetchResult = await this.fetchRemoteSections(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => s.sections.length > 0, + publishFn: (s) => this.publishSections(s), + }); + } + destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. The - // scoped localStorage write is already durable; when the user returns to - // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against community switching and could - // publish relay A's sections to relay B via the shared relayClient - // singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's sections to relay B via the shared relayClient + // singleton. On return, bootstrap's found path whole-blob-replaces from + // remote, so any dropped pending edit is lost. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index 6bd9b48d7b..aa67ca3fb1 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -1,4 +1,4 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import type { Channel } from "@/shared/api/types"; const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1"; diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 76bf57b6c5..28159eedd3 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -3,174 +3,260 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelSortSyncManager } from "./channelSortSync.ts"; +import { + makeFakeWindow, + installFakeWindow, + installTauriMock, +} from "./sidebarSyncTestHelpers.mjs"; function makeStore(groups = {}) { return { version: 1, groups }; } -// ─── destroy() must cancel pending publish, not flush ───────────────────────── +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); -// Regression guard for the community-switch cross-relay publish vector: -// change a sort mode in relay A → destroy() is called (relayUrl dep change) → -// no publish should fire. The scoped localStorage write is durable; when the -// user returns to relay A the seed-publish path handles it. +// ─── destroy() must cancel pending publish, not flush ───────────────────────── test("destroy: cancels pending publish without flushing to the relay", () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - let timerCallback = null; - const fakeTimers = []; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - const originalSetTimeout = globalThis.window.setTimeout; - const originalClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - const id = nextId++; - fakeTimers.push({ id, fn }); - timerCallback = fn; - return id; - }; - globalThis.window.clearTimeout = (id) => { - const idx = fakeTimers.findIndex((t) => t.id === id); - if (idx !== -1) { - fakeTimers.splice(idx, 1); - timerCallback = null; - } - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-test"); - const store = makeStore({ channels: "recent" }); - - manager.publishSortPrefs(store); - assert.ok(timerCallback !== null, "debounce timer should be set"); - + const manager = new ChannelSortSyncManager("pk-test", RELAY); + manager.publishSortPrefs(makeStore({ channels: "recent" })); + assert.ok(fw._hasTimer(), "debounce timer should be set"); manager.destroy(); - - assert.ok( - timerCallback === null, - "debounce timer should be cleared on destroy", - ); - assert.equal( - publishCalls.length, - 0, - "no publish event should have been sent after destroy", - ); + assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingStore(), null); } finally { - if (originalSetTimeout !== undefined) { - globalThis.window.setTimeout = originalSetTimeout; - } - if (originalClearTimeout !== undefined) { - globalThis.window.clearTimeout = originalClearTimeout; - } + restore(); mock.reset(); } }); -// Regression guard for the timer-fired race: debounce fires → doPublish starts -// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep -// change) → publishEvent must never be called even though the timer already -// fired and cleared itself before destroy() ran. +// Regression guard for the timer-fired race: debounce fires → doPublish awaits +// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { let releaseFetch = null; const publishCalls = []; - - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - releaseFetch = () => resolve([]); - }); - }); + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - let capturedCallback = null; - let nextId = 1; - const origSetTimeout = globalThis.window.setTimeout; - const origClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - capturedCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - capturedCallback = null; - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-race"); - const store = makeStore({ dms: "recent" }); - - manager.publishSortPrefs(store); - assert.ok(capturedCallback !== null, "debounce timer should be set"); - - const timerFn = capturedCallback; - capturedCallback = null; // timer cleared itself inside the callback - timerFn(); - + const manager = new ChannelSortSyncManager("pk-race", RELAY); + manager.publishSortPrefs(makeStore({ dms: "recent" })); + fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish manager.destroy(); - releaseFetch(); - - await new Promise((resolve) => setTimeout(resolve, 0)); - + await new Promise((r) => setTimeout(r, 0)); assert.equal( publishCalls.length, 0, - "publishEvent must not be called after destroy() even when timer already fired", + "publishEvent must not fire after destroy", ); } finally { - globalThis.window.setTimeout = origSetTimeout; - globalThis.window.clearTimeout = origClearTimeout; + restore(); mock.reset(); } }); test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSortSyncManager("pk-no-pending"); - assert.doesNotThrow(() => manager.destroy()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } }); -test("destroy: cancelPendingPublish clears pendingStore", () => { - let timerCallback = null; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// Wiring tests 1-3 drive the production bootstrap() path; policy tested once +// in sidebarSyncWatermark.test.mjs. + +// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls +// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); } - const orig = globalThis.window.setTimeout; - const origClear = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - timerCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - timerCallback = null; - }; +}); +// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) +// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-pending-null"); - const store = makeStore({ starred: "recent" }); - manager.publishSortPrefs(store); - assert.deepEqual(manager.getPendingStore(), store); + const manager = new ChannelSortSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); + } +}); - manager.destroy(); +// 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved) +// Mutation: removing the absent+head-0 seed call leaves pendingStore null. +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-fresh", RELAY); assert.equal( - manager.getPendingStore(), + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`, + ), null, - "pendingStore must be null after destroy", ); - assert.ok(timerCallback === null, "timer must be cleared after destroy"); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStore() !== null); } finally { - globalThis.window.setTimeout = orig; - globalThis.window.clearTimeout = origClear; + restore(); + mock.reset(); + } +}); + +// 4. LWW baseline: newer decryptable pre-publish event still wins after an +// undecryptable head was recorded. +// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison +// 200>200=false → local wins instead of remote → wrong content encrypted. +test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + const REMOTE_KEY = "remote-group-from-relay"; + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock( + JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }), + ); + try { + const manager = new ChannelSortSyncManager("pk-lww", RELAY); + await manager.fetchRemoteSortPrefs(); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`, + ) ?? "0", + ) >= 100, + ); + manager.publishSortPrefs(makeStore({ "local-group": "recent" })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + const pt = tauri.capturedPlaintext(); + assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); + assert.ok( + JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups, + `remote groups must win LWW merge — got: ${pt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 5. live-sub: undecryptable event on live path records head before decrypt +// Mutation test: removing recordRemoteHead before decrypt in the live callback +// leaves watermark at 0 after a live event. +test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-live", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, + ), + null, + "watermark starts absent", + ); + await manager.subscribeToSortPrefs(() => {}); + assert.ok( + liveCallback !== null, + "subscribeLive must have captured the callback", + ); + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); + await new Promise((r) => setTimeout(r, 0)); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", + ); + } finally { + restore(); + mock.reset(); } }); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index e23387368d..fe71fe62df 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -10,8 +10,15 @@ import { parseChannelSortPayload, type ChannelSortStore, } from "./channelSortPreference"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-sort"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteSortPrefs = { @@ -44,17 +51,20 @@ async function decryptAndParse( */ export class ChannelSortSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelSortStore | null = null; private lastPublishedStore: ChannelSortStore | null = null; private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteSortPrefs(): Promise { + async fetchRemoteSortPrefs(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SORT], @@ -62,21 +72,33 @@ export class ChannelSortSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; } } + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -110,11 +132,17 @@ export class ChannelSortSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Snapshot the watermark before advancing it: after recordRemoteHead + // runs, lastRemoteCreatedAt equals event.created_at, so the LWW + // comparison remote.createdAt > lastRemoteCreatedAt would always be + // false and silently suppress the merge. + const headBeforeFetch = this.lastRemoteCreatedAt; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; // Sort prefs use whole-blob LWW: take whichever is newer - if (remote.createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = remote.createdAt; + if (remote.createdAt > headBeforeFetch) { return remote.store; } return store; @@ -174,10 +202,7 @@ export class ChannelSortSyncManager { "Timed out publishing channel sort preferences.", "Failed to publish channel sort preferences.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -197,12 +222,11 @@ export class ChannelSortSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -210,14 +234,28 @@ export class ChannelSortSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelSortStore) { + const fetchResult = await this.fetchRemoteSortPrefs(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.groups).length > 0, + publishFn: (s) => this.publishSortPrefs(s), + }); + } + destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. The - // scoped localStorage write is already durable; when the user returns to - // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against community switching and could - // publish relay A's sort prefs to relay B via the shared relayClient - // singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's sort prefs to relay B via the shared relayClient + // singleton. On return, bootstrap's found path whole-blob-replaces from + // remote, so any dropped pending edit is lost. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs new file mode 100644 index 0000000000..b023574467 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelStarSyncManager } from "./channelStarsSync.ts"; +import { + makeFakeWindow, + installFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the community-switch cross-relay publish vector: +// star a channel in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. +test("destroy: cancels pending publish without flushing to the relay", () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-test", RELAY); + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + manager.destroy(); + assert.equal(publishCalls.length, 0, "no publish after destroy"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-race", RELAY); + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not be called after destroy", + ); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: is safe to call with no pending publish", () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-fresh", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-fresh:${RELAY_KEY}`, + ), + null, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStarStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. relay-A / relay-B watermark isolation +// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. +test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + const managerB = new ChannelStarSyncManager("pk-iso", relayB); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayB)}`, + ), + null, + "relay B watermark must be independent of relay A head", + ); + const result = await managerB.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok( + managerB.getPendingStarStore() !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index 6681030d47..a5abec03fb 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -11,8 +11,15 @@ import { parseStarPayload, type ChannelStarStore, } from "./channelStarsStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-stars"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteStars = { @@ -34,16 +41,20 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelStarSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelStarStore | null = null; private lastPublishedStore: ChannelStarStore | null = null; + private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteStars(): Promise { + async fetchRemoteStars(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_STARS], @@ -51,19 +62,31 @@ export class ChannelStarSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingStarPublish(): void { @@ -99,12 +122,11 @@ export class ChannelStarSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Record the raw head before decrypt on the pre-publish path too. + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); return mergeStores(store, remote.store); } catch { return store; @@ -132,6 +154,10 @@ export class ChannelStarSyncManager { private async doPublish(store: ChannelStarStore): Promise { try { const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (community switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { this.pendingStore = null; return; @@ -154,15 +180,13 @@ export class ChannelStarSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); + if (this.destroyed) return; await relayClient.publishEvent( event, "Timed out publishing channel stars.", "Failed to publish channel stars.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -182,12 +206,11 @@ export class ChannelStarSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -195,14 +218,30 @@ export class ChannelStarSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelStarStore) { + const fetchResult = await this.fetchRemoteStars(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, + publishFn: (s) => this.publishStars(s), + }); + } + destroy(): void { - if (this.debounceTimer !== null && this.pendingStore !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - void this.doPublish(this.pendingStore); - } else if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's state to relay B via the shared relayClient + // singleton. Local entries survive because the apply/publish paths merge + // per-entry via mergeStores, so no local work is permanently lost. + this.destroyed = true; + this.cancelPendingStarPublish(); + this.pendingStore = null; } } diff --git a/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs new file mode 100644 index 0000000000..c94d76db70 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs @@ -0,0 +1,85 @@ +// Shared helpers for sidebar sync manager tests. + +export function makeFakeWindow() { + const storage = new Map(); + const ls = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + }; + let timerCallback = null; + let nextTimerId = 100; + return { + localStorage: ls, + setTimeout: (fn, _ms) => { + timerCallback = fn; + return nextTimerId++; + }, + clearTimeout: (_id) => { + timerCallback = null; + }, + _fireTimer: () => { + if (timerCallback) { + const fn = timerCallback; + timerCallback = null; + fn(); + } + }, + _hasTimer: () => timerCallback !== null, + }; +} + +export function installFakeWindow(fw) { + if (typeof globalThis.window === "undefined") globalThis.window = {}; + const origLs = globalThis.window.localStorage; + const origSt = globalThis.window.setTimeout; + const origCt = globalThis.window.clearTimeout; + globalThis.window.localStorage = fw.localStorage; + globalThis.window.setTimeout = fw.setTimeout; + globalThis.window.clearTimeout = fw.clearTimeout; + return () => { + if (origLs !== undefined) globalThis.window.localStorage = origLs; + if (origSt !== undefined) globalThis.window.setTimeout = origSt; + if (origCt !== undefined) globalThis.window.clearTimeout = origCt; + }; +} + +export function installTauriMock(goodCipherPayload) { + const orig = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + let captured = null; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + if (args?.ciphertext === "bad-cipher") + return Promise.reject(new Error("decrypt failed")); + return Promise.resolve(goodCipherPayload); + } + if (cmd === "nip44_encrypt_to_self") { + captured = args?.plaintext ?? null; + return Promise.resolve("ct"); + } + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "eid", + pubkey: "pk-lww", + content: "ct", + created_at: args?.createdAt ?? 0, + kind: args?.kind ?? 0, + tags: args?.tags ?? [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + return { + restore: () => { + if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; + else delete globalThis.window.__TAURI_INTERNALS__; + }, + capturedPlaintext: () => captured, + }; +} diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs new file mode 100644 index 0000000000..0e8cb373c1 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// We need a minimal localStorage stub since we're running in Node. +function withFreshStorage(fn) { + const store = new Map(); + const ls = { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + }; + const orig = globalThis.window?.localStorage; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + globalThis.window.localStorage = ls; + try { + fn(ls); + } finally { + if (orig !== undefined) globalThis.window.localStorage = orig; + else delete globalThis.window.localStorage; + } +} + +const { readWatermark, advanceWatermark, runBootstrap } = await import( + "./sidebarSyncWatermark.ts" +); + +// Relay URLs are normalised (trimmed, lowercase, trailing slash stripped) +// so the same relay written two ways produces the same key. +const RELAY = "wss://relay.example.com"; +const RELAY_ENCODED = encodeURIComponent("wss://relay.example.com"); + +// ── readWatermark ──────────────────────────────────────────────────────────── + +test("readWatermark: returns 0 when no key exists", () => { + withFreshStorage(() => { + assert.equal(readWatermark("pk", "sections", RELAY), 0); + }); +}); + +test("readWatermark: returns 0 when stored value is 0", () => { + withFreshStorage((ls) => { + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "0"); + assert.equal(readWatermark("pk", "sections", RELAY), 0); + }); +}); + +test("readWatermark: returns stored positive integer", () => { + withFreshStorage((ls) => { + ls.setItem( + `buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, + "1700000000", + ); + assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); + }); +}); + +test("readWatermark: scopes by blobType", () => { + withFreshStorage((ls) => { + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "100"); + ls.setItem(`buzz-sync-watermark.v1:sort:pk:${RELAY_ENCODED}`, "200"); + assert.equal(readWatermark("pk", "sections", RELAY), 100); + assert.equal(readWatermark("pk", "sort", RELAY), 200); + }); +}); + +test("readWatermark: normalises relay URL (trailing slash, case)", () => { + withFreshStorage(() => { + // Write with one form, read with another — must produce the same value. + advanceWatermark("pk", "sections", "WSS://Relay.Example.Com/", 999); + assert.equal( + readWatermark("pk", "sections", "wss://relay.example.com"), + 999, + ); + assert.equal( + readWatermark("pk", "sections", "WSS://Relay.Example.Com/"), + 999, + ); + }); +}); + +// ── advanceWatermark ───────────────────────────────────────────────────────── + +test("advanceWatermark: writes when no prior value exists", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 1700000000); + assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); + }); +}); + +test("advanceWatermark: advances when next > current", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 100); + advanceWatermark("pk", "sections", RELAY, 200); + assert.equal(readWatermark("pk", "sections", RELAY), 200); + }); +}); + +test("advanceWatermark: does not regress when next <= current (monotonic)", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 500); + advanceWatermark("pk", "sections", RELAY, 400); // older — must not overwrite + advanceWatermark("pk", "sections", RELAY, 500); // equal — must not overwrite + assert.equal(readWatermark("pk", "sections", RELAY), 500); + }); +}); + +test("advanceWatermark: round-trips across separate reads (simulated restart)", () => { + withFreshStorage(() => { + // Session A writes watermark. + advanceWatermark("pk", "sections", RELAY, 1700000042); + // Session B reads it back. + assert.equal(readWatermark("pk", "sections", RELAY), 1700000042); + }); +}); + +// ── Relay-A / Relay-B isolation ────────────────────────────────────────────── + +test("relay-A watermark does not suppress first-sync on relay-B", () => { + withFreshStorage(() => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + advanceWatermark("pk", "sections", relayA, 1700000100); + assert.equal( + readWatermark("pk", "sections", relayB), + 0, + "relay B watermark must be independent of relay A", + ); + }); +}); + +test("relay-A watermark is preserved after relay-B session", () => { + withFreshStorage(() => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + advanceWatermark("pk", "sections", relayA, 1700000100); + advanceWatermark("pk", "sections", relayB, 1700000200); + assert.equal( + readWatermark("pk", "sections", relayA), + 1700000100, + "relay A head must not be clobbered by relay B activity", + ); + }); +}); + +// ── runBootstrap policy — tested once; mutations to any branch fail here ───── + +function makeBootstrapArgs({ fetchResult, lastHead, localNonEmpty }) { + let n = 0; + return { + args: { + fetchResult, + lastHead, + localStore: { items: localNonEmpty ? ["x"] : [] }, + isLocalNonEmpty: (s) => s.items.length > 0, + publishFn: () => { + n++; + }, + }, + publishCount: () => n, + }; +} + +// Guard: fetch failed → hold, zero publishes. +// Mutation: removing the failed branch causes a seed on first-sync case. +test("runBootstrap: fetch failed returns hold and never calls publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "failed" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called on failed fetch", + ); +}); + +// Guard: fetch absent + prior head > 0 → hold, zero publishes (stale-dev-build case). +// Mutation: setting lastHead to 0 causes a seed. +test("runBootstrap: fetch absent with prior head returns hold and never calls publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 1700000000, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called when prior head exists", + ); +}); + +// Guard: fetch absent + head 0 + local non-empty → publishFn called exactly once, hold returned. +// Mutation: removing the absent+head-0 seed call leaves publishCount at 0. +test("runBootstrap: first-sync (absent + zero head + non-empty local) calls publishFn and returns hold", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 1, + "publishFn must be called exactly once on first-sync", + ); +}); + +// Guard: fetch absent + head 0 + empty local → no publish, hold returned. +test("runBootstrap: first-sync with empty local store does not call publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 0, + localNonEmpty: false, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal(publishCount(), 0, "empty local store must not trigger seed"); +}); + +// Guard: fetch found → apply-remote returned, no publish. +// Mutation: removing the found branch drops the remote data. +test("runBootstrap: fetch found returns apply-remote with data and never calls publishFn", () => { + const remoteData = { + store: { version: 1, items: [] }, + createdAt: 100, + eventId: "e1", + }; + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { + status: "found", + data: remoteData, + createdAt: 100, + eventId: "e1", + }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "apply-remote"); + assert.deepEqual(result.data, remoteData); + assert.equal( + publishCount(), + 0, + "publishFn must not be called when remote was found", + ); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts new file mode 100644 index 0000000000..d81b188ad6 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -0,0 +1,135 @@ +/** + * Persisted remote-head watermark for sidebar-preference sync managers. + * + * Each manager (sections, sort, stars, mutes) persists the highest + * `created_at` it has ever observed from the relay under a key scoped to + * pubkey + relay + blob type. On the next boot the manager reads this value + * back: if it is > 0 a remote blob has existed before and seed-publishing + * must be skipped even when the fetch comes back empty (error, timeout, or + * auth-race). + * + * Keys live in localStorage alongside the payload blobs. They are tiny + * (one integer string per key) and scoped so they never bleed across + * identities, communities, or blob types. + * + * `relayUrl` is always required — a pubkey-only fallback is not safe because + * a head seen on relay A would suppress legitimate first-time seeding on + * relay B. The URL is normalised (trimmed, trailing slash stripped, + * lower-cased) before being embedded in the key so the same relay written + * two ways never produces two different keys. + */ + +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; + +const PREFIX = "buzz-sync-watermark.v1"; + +/** + * Tri-state result returned by every `fetchRemote*()` method. + * + * - `found` — the relay returned an event that decrypted and parsed cleanly. + * - `absent` — the relay was successfully queried and returned zero events + * (genuine first-time use on this relay). + * - `failed` — the fetch threw (timeout, relay error, auth-race), or an event + * existed but could not be decrypted/parsed. In the `failed` + * case, `createdAt` may be set when the event itself was readable + * even though its payload was not — the manager records the head + * so seed-publish is still blocked. + */ +export type FetchResult = + | { status: "found"; data: T; createdAt: number; eventId: string } + | { status: "absent" } + | { status: "failed"; createdAt?: number }; + +function watermarkKey( + pubkey: string, + blobType: string, + relayUrl: string, +): string { + return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** Read the persisted watermark (0 when absent or on read error). */ +export function readWatermark( + pubkey: string, + blobType: string, + relayUrl: string, +): number { + try { + const raw = window.localStorage.getItem( + watermarkKey(pubkey, blobType, relayUrl), + ); + if (raw === null) return 0; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 0; + } catch { + return 0; + } +} + +/** + * Persist a new watermark if it is strictly greater than the current value. + * Absence or error never lowers the watermark (monotonic). + */ +export function advanceWatermark( + pubkey: string, + blobType: string, + relayUrl: string, + next: number, +): void { + try { + const current = readWatermark(pubkey, blobType, relayUrl); + if (next <= current) return; + window.localStorage.setItem( + watermarkKey(pubkey, blobType, relayUrl), + String(next), + ); + } catch { + // Ignore write failures — the in-memory lastRemoteCreatedAt still guards + // seed-publish within this session; the watermark is belt-and-suspenders + // across sessions. + } +} + +/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ +export type BootstrapResult = + | { action: "apply-remote"; data: T } + | { action: "hold" }; + +/** + * Shared boot policy for all four sidebar-preference sync managers. + * + * Each manager calls this from its `bootstrap()` method, supplying its + * surface-specific fetch, publish, and local-store accessors. The full + * decision lives here once so that a mutation to any one surface cannot + * escape via a per-manager copy. + * + * Policy: + * - `found` → return `apply-remote`; hook applies data. + * - `failed` → hold; seed-publish blocked (error or unreadable event). + * - `absent` + `lastHead > 0` → hold; relay blob seen before, absence may be transient. + * - `absent` + `lastHead === 0` + non-empty local → call `publishFn(local)`; return `hold`. + * - `absent` + `lastHead === 0` + empty local → hold; nothing to seed. + */ +export function runBootstrap({ + fetchResult, + lastHead, + localStore, + isLocalNonEmpty, + publishFn, +}: { + fetchResult: FetchResult; + lastHead: number; + localStore: TLocal; + isLocalNonEmpty: (store: TLocal) => boolean; + publishFn: (store: TLocal) => void; +}): BootstrapResult { + if (fetchResult.status === "found") { + return { action: "apply-remote", data: fetchResult.data }; + } + if (fetchResult.status === "absent" && lastHead === 0) { + if (isLocalNonEmpty(localStore)) { + publishFn(localStore); + } + } + return { action: "hold" }; +} diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 1fe92b60a3..cab913834d 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -14,7 +14,10 @@ import { import { ChannelMuteSyncManager } from "./channelMutesSync"; import type { RemoteMutes } from "./channelMutesSync"; -export function useChannelMutes(pubkey: string | undefined): { +export function useChannelMutes( + pubkey: string | undefined, + relayUrl?: string, +): { mutedChannelIds: Set; muteChannel: (channelId: string) => void; unmuteChannel: (channelId: string) => void; @@ -31,7 +34,7 @@ export function useChannelMutes(pubkey: string | undefined): { const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -40,12 +43,12 @@ export function useChannelMutes(pubkey: string | undefined): { setStore(readChannelMutesStore(pubkey)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelMuteSyncManager(pubkey); + managerRef.current = new ChannelMuteSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; }; - }, [pubkey]); + }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) { @@ -86,24 +89,22 @@ export function useChannelMutes(pubkey: string | undefined): { ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteMutes().then((remote) => { + const local = readChannelMutesStore(pubkey); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelMutesStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishMutes(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -124,16 +125,17 @@ export function useChannelMutes(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body React.useEffect(() => { if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteMutes().then((remote) => { + void managerRef.current?.fetchRemoteMutes().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingMuteStore(); if (pending) { @@ -145,7 +147,7 @@ export function useChannelMutes(pubkey: string | undefined): { cancelled = true; unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const mutedChannelIds = React.useMemo( diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 2ba659a484..3d8aa73608 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -45,7 +45,7 @@ export function useChannelSections( const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -54,7 +54,7 @@ export function useChannelSections( setStore(readChannelSectionsStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelSectionSyncManager(pubkey); + managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; @@ -102,18 +102,16 @@ export function useChannelSections( ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteSections().then((remote) => { + const local = readChannelSectionsStore(pubkey, relayUrl); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelSectionsStore(pubkey, relayUrl); - if (local.sections.length > 0) { - managerRef.current?.publishSections(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or + // blocked (failed fetch / prior watermark). Hook does nothing. }); return () => { cancelled = true; @@ -146,10 +144,10 @@ export function useChannelSections( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSections().then((remote) => { + void managerRef.current?.fetchRemoteSections().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index e347d41a9d..a7963a11e4 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -49,7 +49,7 @@ export function useChannelSortPreference( const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -58,7 +58,7 @@ export function useChannelSortPreference( setStore(readChannelSortStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelSortSyncManager(pubkey); + managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; @@ -101,18 +101,15 @@ export function useChannelSortPreference( ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + const local = readChannelSortStore(pubkey, relayUrl); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelSortStore(pubkey, relayUrl); - if (Object.keys(local.groups).length > 0) { - managerRef.current?.publishSortPrefs(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; @@ -145,10 +142,10 @@ export function useChannelSortPreference( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + void managerRef.current?.fetchRemoteSortPrefs().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 777bf52cfd..b19b18a864 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -14,7 +14,10 @@ import { import { ChannelStarSyncManager } from "./channelStarsSync"; import type { RemoteStars } from "./channelStarsSync"; -export function useChannelStars(pubkey: string | undefined): { +export function useChannelStars( + pubkey: string | undefined, + relayUrl?: string, +): { starredChannelIds: Set; starChannel: (channelId: string) => void; unstarChannel: (channelId: string) => void; @@ -31,7 +34,7 @@ export function useChannelStars(pubkey: string | undefined): { const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -40,12 +43,12 @@ export function useChannelStars(pubkey: string | undefined): { setStore(readChannelStarsStore(pubkey)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelStarSyncManager(pubkey); + managerRef.current = new ChannelStarSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; }; - }, [pubkey]); + }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) { @@ -86,24 +89,22 @@ export function useChannelStars(pubkey: string | undefined): { ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteStars().then((remote) => { + const local = readChannelStarsStore(pubkey); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelStarsStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishStars(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -124,16 +125,17 @@ export function useChannelStars(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body React.useEffect(() => { if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteStars().then((remote) => { + void managerRef.current?.fetchRemoteStars().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStarStore(); if (pending) { @@ -145,7 +147,7 @@ export function useChannelStars(pubkey: string | undefined): { cancelled = true; unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const starredChannelIds = React.useMemo( diff --git a/desktop/src/shared/lib/normalizeRelayUrl.ts b/desktop/src/shared/lib/normalizeRelayUrl.ts new file mode 100644 index 0000000000..7222b1fe25 --- /dev/null +++ b/desktop/src/shared/lib/normalizeRelayUrl.ts @@ -0,0 +1,8 @@ +/** + * Normalizes a relay URL for use in storage keys. + * Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to + * the same key regardless of formatting differences. + */ +export function normalizeRelayUrl(relayUrl: string): string { + return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); +} From 1399ec1d13c4560f50fd947e504deeea70929751 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:28:25 -0400 Subject: [PATCH 13/16] Alert community owners and admins when a new key joins (#4900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners and admins of a Buzz community get a desktop notification the first time a new key joins their community. Requested by Tyler in buzz-development ("we already have this [roster] — can we alert owners and admins when a new key joins for the first time?"); design and verification thread: channel `community-members-visibility`. ## Why the shape is what it is - **kind:13534 membership snapshot is the alerting signal, not the kind:8000 delta.** 8000 is leaky on two independent axes: its fan-out is pod-local (no Redis hop — being fixed separately in #4887), and `buzz-admin add-member` publishes no 8000 at all by documented design. The 13534 snapshot is the only signal covering every production join path with cross-pod delivery (completeness audit: every membership-insertion path enumerated at base `8342dfcc5`, all emit 13534). - **This adds Desktop's first live 13534 subscription** — deliberate line item. The existing read (`relayMembers.ts`) is a one-shot fetch; without a live subscription no snapshot ever arrives passively and nothing could fire. - **8000 is subscribed only as a latency accelerator** and it *refetches the authoritative snapshot* rather than alerting from its own payload, so one ledger governs both signals and they cannot double-alert. - **Persisted per-community/per-viewer ledger, written before the notification fires.** Snapshot publication is eventual (60s reconciler repairs failed best-effort publishes) and a reconciler-republished snapshot is indistinguishable from a fresh one — only a durable record answers "is this new". Also what makes reconnect replay (`since - 5s` skew; `since === undefined` full-backlog edge) safe. - **First snapshot per community seeds silently** (no notification storm for existing members), and `seeded` is an explicit persisted bit — not inferred from ledger non-emptiness, which would swallow the first genuine join in a community whose only member is the viewer. - Mounted in `useAppShellDesktopNotifications` (owns the notifications-enabled precondition; `AppShell.tsx` is at the file-size ratchet ceiling — net growth zero). 5 files, +3103 (production +752, tests +2,351), desktop-only. No relay changes. ## Verification **Current reviewed tip: `1854c4a5` — review-blessed code at `d992ed295ead8c8423f81a752f4ad614718d85c6`** (clean tree, HEAD checked in the same shell as each gate; history is `0e791f2d3` → merge of main `2034e693a` → `fdeda44f0` → `5d0d2b4c3` → `a20a7d8cb` → merge of main `0cfe4832` → `d992ed29` → `1854c4a5`, all fast-forward, no rebase or force). Independently gated by Eva, Wren, and Sami: typecheck rc=0, `pnpm check` rc=0 (pre-existing 1 warning / 2 infos), full Desktop unit package **4431/4431**; push hooks pass. Wren's adversarial verdict at `d992ed29`: APPROVE — minimalness 9, elegance 9, correctness 9, all four cancellation seams plus 1b re-derived independently. `1854c4a5` is assertions and comments only — no production behaviour change, so the test count is unchanged. **Remediation commits (review thread `community-members-visibility`):** - `fdeda44f0` — authorization read from the signed snapshot being reconciled (a demoting/removing snapshot fails closed before it can disclose the joins it carries); >3 joins collapse to one summary; 8000-triggered refetches coalesce on a 500ms trailing window. - `5d0d2b4c3` — join alerts coalesce **across** snapshots, not just within one: a live burst arrives as several growing rosters, so delivery defers onto a 1.5s trailing quiet window while ledger persistence and dedupe stay synchronous per snapshot. Max measured 10 banners from 50 real joins before this; the same shape now produces one. - `a20a7d8cb` — cancellation covers flushes already in flight, not just queued timers: a generation token (bumped only by `clearPending`) is rechecked after the profile lookup and before every send, so demotion/removal/unmount/community-switch landing mid-flush suppresses delivery; the notification title is captured with the batch rather than read at send time. Concurrent-flush semantics pinned: a newer authorized batch neither cancels nor is cancelled by an in-flight flush. - `d992ed29` — the stale-authorized-frame disclosure, independently reproduced at `0cfe4832` (held-open refetch released after a newer demoting frame: `notifications=1`, body naming the joiner, where 0 is required). Three fixes in one shape: every callback acts on a per-effect-run session object (community id, viewer, ledger, ordering state) instead of ambient current values, closing the community-switch window; a `created_at` fence plus a fail-closed revocation latch, as one mechanism, because the relay can publish two snapshots in the same second so neither `<` nor `<=` alone is safe — the invariant is “revocation wins”, not “newest wins”; and a 5s clamp on the 1.5s trailing window so a sustained drip cannot defer delivery without bound. Red-first: the four new arms fail at `0cfe4832` (25/29) and pass after (29/29). - `1854c4a5` — the privacy arm now asserts the persisted ledger is unchanged across the delayed frame's release, not only the notification count. Mutation-checked: moving the revoked check after the ledger advance keeps notifications at 0 and passes the old assertion, and is killed by the new one. Assertions and comments only. **Mutation testing:** 9/9 mounted-hook mutants killed at `a20a7d8cb`, each with a control row before and after — role/enabled gates, reconnect, 8000 authority, failed-write handling and ref ordering, community re-key/read, and query invalidation. The reducer/storage fix separately killed 6/6 mutants with 15/0 controls; the foundational ledger suite killed 9/9. At `d992ed29`: spelling the fence `<=` kills 5 arms; moving the empty-roster guard after the fence advance kills exactly the fence-advance arm and nothing else (28/29). One qualification stated rather than buried — moving the fence advance itself up to the comparison SURVIVES the whole suite. That is an equivalent mutant, not a coverage gap: the empty-roster guard returns before the comparison, and authorization rejection latches `revoked` so a later frame having moved the fence is unobservable. The scope is written into the test's docstring. At `1854c4a5`: the revoked-check-after-ledger-advance mutant is killed by the new ledger assertion (and by the 1b arm). **Scale/storage correction in `f6e5a3c57`:** the original 5,000-key cap could evict members still present in a 5,001+ roster, causing them to re-alert on every snapshot; read-time truncation reopened the same loop after reload; and a raw quota exception could reject before notification dispatch. The fix retains every on-roster key, caps only departed keys, removes read-time truncation, and uses the app's quota-aware writer. **Final ordering correction in `0e791f2d3`:** a failed post-recovery write now skips notification and leaves the in-memory ledger unchanged, so the next snapshot retries and delivers only after persistence succeeds. **Live-local matrix vs a real relay, executed at exact unchanged `d75cc6cd9` and transferred to the current tip:** a 4,800-sequence differential found zero old/new reducer divergences below the cap while exercising the positive alert path; its negative control diverged as required at 5,100 members (old re-alerts 100; new re-alerts 0). The final hook change affects only the newly tested failed-write branch; successful writes follow the same alert path exercised live. The live communities were sub-cap and persisted successfully, so the matrix remains applicable without a redundant rerun. - Invite claim: owner and admin each exactly one notification; plain member zero; 1.5s quiet window held (8000+13534 deduped); both open clients live-refreshed the roster. Screenshot receipts SHA-256-pinned and independently replicated. - **CLI `buzz-admin add-member` (13534-only path):** DB counts moved 8000 `9→9`, 13534 `15→16` — zero accelerator events, exactly one alert per manager. Proves snapshot-diff alone alerts. - Plain member: zero notifications **and** zero `buzz-community-join-seen.v1:*` localStorage keys before/after the join (gate sits before the ledger). - Staggered reload + replay dedupe: no alerts from startup refetch/replay; republished already-seen snapshot produced zero through a 2s quiet window. - Community switch: independent per-community seed state; effect re-keys; one alert per community, quiet window held at exactly two. **Live re-verification at `d992ed29` is in progress** (Max; the after-fix matrix leads with the delayed-refetch demotion arm, A→B switch ledger isolation, the 5s sustained-drip timing, and packaged-app click routing behind the positive/NIP-43 controls); earlier receipts at `a20a7d8cb` cover the instrumented storm and cap-boundary re-drive; earlier live receipts at `fdeda44f0` — privacy matrix (demote/remove/promote), summary click-through — transfer where the diff left those paths untouched. ## Known and accepted - **8000 cross-pod fan-out is broken relay-side** — fixed in #4887 (separate lane, not a blocker here): on a multi-pod relay the accelerator only fires on the claim-handling pod; 13534 still covers everyone, just not instantly. - **Late-not-lost semantics.** A live frame missed during a reload/socket gap is recovered by the next snapshot, reconnect refetch, or remount backfill (`limit: 1`) diffed against the persisted ledger. One live-run observation of an admin missing an immediate post-reload fresh join is attributed to harness rate limiting; the recovery paths above bound the damage to lateness, never duplicates. - **Remote promotion activates on reload, not on the next snapshot** (measured by Sami at `fdeda44f0`): the subscriptions are mounted from the cached membership lookup, so a viewer promoted to admin by someone else starts receiving join alerts only after a reload, community switch, or local membership mutation refreshes that cache. Fails safe (under-notify). Ruled accepted for v1 by Eva; the fix direction (subscribing before authorization) is a deliberate design change deferred to a follow-up if product wants instant activation. - **Cross-user live-delivery staleness reproduced at the PR's own base** (`2034e693a`, clean relay): a persisted send can fail to appear in an already-open recipient timeline. Detached from this PR by a pinned-base discriminator (identical failure with zero PR code) and tracked separately in issue `6e2bda3092fa`; current main passes 4/4. - **A stale demoting frame latches a genuine admin until reload or community switch** (reverse ordering of the stale-frame privacy race, `d992ed29`): if a snapshot that does not list the viewer as a manager arrives out of order, the fail-closed revocation latch trips even though the viewer is still an admin. The invalidation the latch fires refetches the membership lookup, which correctly returns admin, so `active` stays true, the effect deps do not change, and the session stays latched. Fails safe (under-notify, never over-disclose) and consistent with the promotion-on-reload semantics above. Ruled accepted for v1 by Eva; self-clearing the latch would cost a third piece of timing state. Pinned as documented behaviour in `useCommunityJoinAlerts.test.mjs` — and the suppressed join is re-announced rather than lost, because a latched session never records it in the ledger. - **Lifetime-first-only semantics:** ever-seen ledger means remove→re-add does not re-alert. Flagged for product ruling; one-line change if re-adds should ping. --------- Signed-off-by: Sami Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Sami Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../app/useAppShellDesktopNotifications.ts | 8 + .../community-members/lib/joinAlerts.test.mjs | 265 ++ .../community-members/lib/joinAlerts.ts | 227 ++ .../useCommunityJoinAlerts.test.mjs | 2236 +++++++++++++++++ .../useCommunityJoinAlerts.ts | 538 ++++ 5 files changed, 3274 insertions(+) create mode 100644 desktop/src/features/community-members/lib/joinAlerts.test.mjs create mode 100644 desktop/src/features/community-members/lib/joinAlerts.ts create mode 100644 desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs create mode 100644 desktop/src/features/community-members/useCommunityJoinAlerts.ts diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index f739e900df..6792faf21a 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -5,6 +5,7 @@ import { toSearchHit, } from "@/app/AppShell.helpers"; import { getThreadReference } from "@/features/messages/lib/threading"; +import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; import { @@ -45,6 +46,13 @@ export function useAppShellDesktopNotifications({ pubkey?: string; silentChannelIds?: ReadonlySet; }) { + // Roster alerts are owner/admin-only and self-gating; mounted here because + // it shares this hook's "desktop notifications are on" precondition and + // AppShell sits at the file-size ratchet ceiling. + useCommunityJoinAlerts({ + enabled: enabled && notificationSettings.desktopEnabled, + }); + const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { if (!enabled) return; diff --git a/desktop/src/features/community-members/lib/joinAlerts.test.mjs b/desktop/src/features/community-members/lib/joinAlerts.test.mjs new file mode 100644 index 0000000000..f7346bd5e9 --- /dev/null +++ b/desktop/src/features/community-members/lib/joinAlerts.test.mjs @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + EMPTY_JOIN_ALERT_LEDGER, + JOIN_ALERT_DEPARTED_MAX_ITEMS, + joinAlertBody, + joinAlertTitle, + readJoinAlertLedger, + reconcileJoinAlertLedger, + writeJoinAlertLedger, +} from "./joinAlerts.ts"; + +const COMMUNITY = "community-1"; +const OWNER = "a".repeat(64); +const ALICE = "b".repeat(64); +const BOB = "c".repeat(64); + +function installLocalStorage({ throwOnSet = false } = {}) { + const values = new Map(); + globalThis.window = { + localStorage: { + get length() { + return values.size; + }, + key: (index) => [...values.keys()][index] ?? null, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + if (throwOnSet) { + const error = new Error("quota exceeded"); + error.name = "QuotaExceededError"; + throw error; + } + values.set(key, value); + }, + removeItem: (key) => values.delete(key), + }, + }; + return values; +} + +/** Fold a roster in and persist, the way the hook does. */ +function applySnapshot(ledger, rosterPubkeys) { + const result = reconcileJoinAlertLedger({ + ledger, + rosterPubkeys, + viewerPubkey: OWNER, + }); + if (result.changed) { + writeJoinAlertLedger(COMMUNITY, OWNER, result.ledger); + } + return result; +} + +test("first snapshot seeds an existing roster without alerting", () => { + installLocalStorage(); + + const result = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER, ALICE, BOB]); + + assert.deepEqual(result.alerts, []); + assert.equal(result.ledger.seeded, true); + assert.deepEqual(result.ledger.pubkeys, [ALICE, BOB]); +}); + +test("a key joining after the seed alerts exactly once", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER, ALICE]).ledger; + const joined = applySnapshot(seeded, [OWNER, ALICE, BOB]); + + assert.deepEqual(joined.alerts, [BOB]); + + // A redelivered identical snapshot must not re-alert or rewrite. + const redelivered = applySnapshot(joined.ledger, [OWNER, ALICE, BOB]); + assert.deepEqual(redelivered.alerts, []); + assert.equal(redelivered.changed, false); +}); + +test("a community seeded with only the viewer still alerts on the first join", () => { + // Regression: inferring "seeded" from a non-empty ledger classified this + // first genuine join as the seeding run and dropped the alert silently. + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]); + assert.deepEqual(seeded.alerts, []); + assert.deepEqual(seeded.ledger.pubkeys, []); + assert.equal(seeded.ledger.seeded, true); + + const joined = applySnapshot(seeded.ledger, [OWNER, ALICE]); + assert.deepEqual(joined.alerts, [ALICE]); +}); + +test("the seeded flag survives a reload through storage", () => { + installLocalStorage(); + + applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]); + const reloaded = readJoinAlertLedger(COMMUNITY, OWNER); + + assert.equal(reloaded.seeded, true); + assert.deepEqual(reloaded.pubkeys, []); + assert.deepEqual(applySnapshot(reloaded, [OWNER, ALICE]).alerts, [ALICE]); +}); + +test("remove then re-add does not alert a second time", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger; + assert.deepEqual(applySnapshot(seeded, [OWNER, ALICE]).alerts, [ALICE]); + + const afterRemoval = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [ + OWNER, + ]); + assert.deepEqual(afterRemoval.alerts, []); + + const afterReAdd = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [ + OWNER, + ALICE, + ]); + assert.deepEqual(afterReAdd.alerts, []); +}); + +test("the kind:8000 accelerator and the live snapshot yield one alert", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger; + + // Delta arrives first and triggers a snapshot refetch... + const viaDelta = applySnapshot(seeded, [OWNER, ALICE]); + assert.deepEqual(viaDelta.alerts, [ALICE]); + + // ...then the live 13534 for the same join lands. + const viaLive = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [ + OWNER, + ALICE, + ]); + assert.deepEqual(viaLive.alerts, []); +}); + +test("the viewer is never alerted on or recorded", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [ALICE]).ledger; + const result = applySnapshot(seeded, [ALICE, OWNER]); + + assert.deepEqual(result.alerts, []); + assert.equal(result.changed, false); + assert.equal(result.ledger.pubkeys.includes(OWNER), false); +}); + +test("roster pubkeys are matched case-insensitively", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger; + const joined = applySnapshot(seeded, [OWNER, ALICE.toUpperCase()]); + + assert.deepEqual(joined.alerts, [ALICE]); + assert.deepEqual(applySnapshot(joined.ledger, [OWNER, ALICE]).alerts, []); +}); + +test("a duplicated pubkey in one snapshot alerts once", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger; + const joined = applySnapshot(seeded, [OWNER, ALICE, ALICE]); + + assert.deepEqual(joined.alerts, [ALICE]); + assert.deepEqual(joined.ledger.pubkeys, [ALICE]); +}); + +test("a ledger stored before the seeded flag existed is treated as seeded", () => { + const values = installLocalStorage(); + const [key] = [...values.keys()]; + writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] }); + const storageKey = key ?? [...values.keys()][0]; + values.set(storageKey, JSON.stringify({ pubkeys: [ALICE] })); + + const ledger = readJoinAlertLedger(COMMUNITY, OWNER); + assert.equal(ledger.seeded, true); + assert.deepEqual(applySnapshot(ledger, [OWNER, ALICE, BOB]).alerts, [BOB]); +}); + +test("unreadable storage reads as an unseeded ledger", () => { + const values = installLocalStorage(); + writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] }); + values.set([...values.keys()][0], "{not json"); + + assert.deepEqual(readJoinAlertLedger(COMMUNITY, OWNER), { + seeded: false, + pubkeys: [], + }); +}); + +test("a roster larger than the departed cap never re-alerts its own members", () => { + // Regression: capping *all* retained keys shed pubkeys that were still on the + // roster, so the next snapshot saw them as unknown and alerted again — every + // snapshot, forever, for any community past the cap. + installLocalStorage(); + + const roster = Array.from( + { length: JOIN_ALERT_DEPARTED_MAX_ITEMS + 100 }, + (_unused, index) => index.toString(16).padStart(64, "0"), + ); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, roster); + assert.deepEqual(seeded.alerts, []); + assert.equal(seeded.ledger.pubkeys.length, roster.length); + + for (let pass = 0; pass < 3; pass++) { + const repeat = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), roster); + assert.deepEqual(repeat.alerts, []); + assert.equal(repeat.changed, false); + } + + // The read path must not truncate either: a stored ledger above the cap has + // to come back whole or the same re-alert loop reopens on reload. + assert.equal( + readJoinAlertLedger(COMMUNITY, OWNER).pubkeys.length, + roster.length, + ); +}); + +test("the cap sheds only departed pubkeys, oldest first", () => { + installLocalStorage(); + + const roster = Array.from( + { length: JOIN_ALERT_DEPARTED_MAX_ITEMS + 10 }, + (_unused, index) => index.toString(16).padStart(64, "0"), + ); + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, roster).ledger; + + // Everyone leaves except the newest member; one new key joins. + const survivor = roster.at(-1); + const shrunk = applySnapshot(seeded, [OWNER, survivor, BOB]); + + assert.deepEqual(shrunk.alerts, [BOB]); + // 5010 retained - 9 departed over the cap, plus BOB. + assert.equal(shrunk.ledger.pubkeys.length, roster.length - 9 + 1); + assert.equal(shrunk.ledger.pubkeys.includes(roster[0]), false); + assert.equal(shrunk.ledger.pubkeys.includes(roster[8]), false); + assert.equal(shrunk.ledger.pubkeys.includes(roster[9]), true); + // The on-roster key is retained no matter where it sits in insertion order. + assert.equal(shrunk.ledger.pubkeys.includes(survivor), true); +}); + +test("a write that cannot land is reported, not thrown", () => { + // The writer runs inside an async snapshot handler: a raw QuotaExceededError + // would reject before the notification is sent, on every snapshot. + installLocalStorage({ throwOnSet: true }); + + assert.equal( + writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] }), + false, + ); + assert.deepEqual(readJoinAlertLedger(COMMUNITY, OWNER), { + seeded: false, + pubkeys: [], + }); +}); + +test("notification copy names the community when known", () => { + assert.equal(joinAlertTitle("Buzz HQ"), "New member in Buzz HQ"); + assert.equal(joinAlertTitle(" "), "New community member"); + assert.equal(joinAlertTitle(null), "New community member"); + assert.equal(joinAlertBody("Alice"), "Alice joined"); +}); diff --git a/desktop/src/features/community-members/lib/joinAlerts.ts b/desktop/src/features/community-members/lib/joinAlerts.ts new file mode 100644 index 0000000000..2a3ed3fa95 --- /dev/null +++ b/desktop/src/features/community-members/lib/joinAlerts.ts @@ -0,0 +1,227 @@ +/** + * First-join alert bookkeeping for community owners/admins. + * + * # Why the roster snapshot is the source of truth, not the kind:8000 delta + * + * The relay emits a kind:8000 "member-added" delta on the invite-claim and + * relay-admin paths, but `buzz-admin add-member` deliberately emits none + * (`crates/buzz-admin/src/main.rs:6-13`), and kind:8000 fan-out is pod-local + * (`fan_out_event_to_local_subscribers` never calls `publish_event`, unlike + * `dispatch_persistent_event_inner`). The kind:13534 membership snapshot is the + * only signal that covers every join path *and* propagates across pods, so it + * is the correctness signal here; kind:8000 is a latency accelerator only. + * + * # Why a persisted ledger rather than snapshot-to-snapshot diffing + * + * Snapshot publication is eventual, not transactional: a failed post-commit + * publish is repaired by the relay's periodic reconciler, so the same member + * can first appear in a snapshot arriving up to a reconcile interval late, and + * a reconciler-published snapshot is indistinguishable from a fresh one. Only a + * ledger of pubkeys we have already alerted on can answer "is this new to the + * user", which is the question the notification actually asks. The ledger also + * absorbs kind:8000 redelivery on reconnect, where the replay filter re-sends + * events at or after `lastSeenCreatedAt - skew` and can repeat a seen delta. + */ + +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +const JOIN_ALERT_STORAGE_PREFIX = "buzz-community-join-seen.v1"; + +/** + * Cap on *departed* pubkeys retained per community. + * + * A pubkey still on the roster can never be shed: the next snapshot presents it + * again, the ledger no longer recognizes it, and it is alerted as a fresh join + * — on every snapshot, forever. So the cap bounds only the tail of keys that + * have left, and the ledger's real ceiling is the roster the relay can deliver + * (a kind:13534 snapshot larger than `BUZZ_MAX_FRAME_BYTES` never arrives). + */ +export const JOIN_ALERT_DEPARTED_MAX_ITEMS = 5_000; + +export type JoinAlertLedger = { + /** + * Whether a roster snapshot has already been folded in for this community. + * + * Tracked explicitly rather than inferred from `pubkeys.length > 0`, because + * the two are not the same proposition: a community whose only member is the + * viewer seeds to an *empty* pubkey list (the viewer is never recorded), and + * inferring from emptiness would then classify the first genuine join as the + * seeding run and silently swallow the very alert this feature exists for. + */ + seeded: boolean; + /** Pubkeys already alerted on, oldest first. */ + pubkeys: string[]; +}; + +export const EMPTY_JOIN_ALERT_LEDGER: JoinAlertLedger = { + seeded: false, + pubkeys: [], +}; + +export function joinAlertStorageKey(communityId: string, viewerPubkey: string) { + return `${JOIN_ALERT_STORAGE_PREFIX}:${communityId}:${viewerPubkey}`; +} + +export function normalizeJoinPubkey(pubkey: string): string { + return pubkey.trim().toLowerCase(); +} + +export function readJoinAlertLedger( + communityId: string, + viewerPubkey: string, +): JoinAlertLedger { + if ( + typeof window === "undefined" || + communityId.length === 0 || + viewerPubkey.length === 0 + ) { + return EMPTY_JOIN_ALERT_LEDGER; + } + + const rawValue = window.localStorage.getItem( + joinAlertStorageKey(communityId, viewerPubkey), + ); + if (!rawValue) { + return EMPTY_JOIN_ALERT_LEDGER; + } + + try { + const parsed: unknown = JSON.parse(rawValue); + if (parsed === null || typeof parsed !== "object") { + return EMPTY_JOIN_ALERT_LEDGER; + } + + const { pubkeys, seeded } = parsed as Partial; + if (!Array.isArray(pubkeys)) { + return EMPTY_JOIN_ALERT_LEDGER; + } + + return { + // A stored ledger is by definition the residue of a snapshot we already + // folded in, so unreadable/absent `seeded` reads as true. Defaulting the + // other way would re-seed and drop a real join. + seeded: seeded !== false, + pubkeys: pubkeys.filter( + (value): value is string => typeof value === "string", + ), + }; + } catch { + return EMPTY_JOIN_ALERT_LEDGER; + } +} + +/** + * Persist the ledger. Returns false when the write did not land. + * + * Routed through the quota-aware writer rather than `localStorage.setItem`: + * this runs inside an async snapshot handler, where a raw QuotaExceededError + * would reject before the notification is ever sent, and it would do so on + * every subsequent snapshot too. + */ +export function writeJoinAlertLedger( + communityId: string, + viewerPubkey: string, + ledger: JoinAlertLedger, +): boolean { + if ( + typeof window === "undefined" || + communityId.length === 0 || + viewerPubkey.length === 0 + ) { + return false; + } + + return setLocalStorageItemWithRecovery( + joinAlertStorageKey(communityId, viewerPubkey), + JSON.stringify(ledger satisfies JoinAlertLedger), + ); +} + +/** + * Fold a roster snapshot into the ledger, returning the pubkeys to alert on. + * + * The viewer's own pubkey is never alerted on or recorded: an owner does not + * need to be told they joined their own community. + * + * `alerts` is empty on the seeding run — the first snapshot for a community + * records every existing member silently, so installing the app against an + * established roster does not produce a notification per member. + */ +export function reconcileJoinAlertLedger({ + ledger, + rosterPubkeys, + viewerPubkey, +}: { + ledger: JoinAlertLedger; + rosterPubkeys: readonly string[]; + viewerPubkey: string; +}): { alerts: string[]; changed: boolean; ledger: JoinAlertLedger } { + const normalizedViewer = normalizeJoinPubkey(viewerPubkey); + const seen = new Set(ledger.pubkeys); + const roster = new Set(); + const fresh: string[] = []; + + for (const rawPubkey of rosterPubkeys) { + const pubkey = normalizeJoinPubkey(rawPubkey); + if (pubkey.length === 0) continue; + if (pubkey === normalizedViewer) continue; + roster.add(pubkey); + if (seen.has(pubkey)) continue; + seen.add(pubkey); + fresh.push(pubkey); + } + + if (fresh.length === 0 && ledger.seeded) { + return { alerts: [], changed: false, ledger }; + } + + // Shed only pubkeys absent from the roster we were just handed. Capping the + // whole ledger instead would evict keys that are still members, and every + // later snapshot would then re-alert them — permanently, once the roster + // passes the cap. + const departed = ledger.pubkeys.filter((pubkey) => !roster.has(pubkey)); + const shedCount = departed.length - JOIN_ALERT_DEPARTED_MAX_ITEMS; + const shed = shedCount > 0 ? new Set(departed.slice(0, shedCount)) : null; + const retained = + shed === null + ? ledger.pubkeys + : ledger.pubkeys.filter((pubkey) => !shed.has(pubkey)); + + return { + alerts: ledger.seeded ? fresh : [], + changed: true, + ledger: { + seeded: true, + pubkeys: [...retained, ...fresh], + }, + }; +} + +/** Notification copy for a single first join. */ +export function joinAlertTitle(communityName: string | null | undefined) { + const trimmed = communityName?.trim(); + return trimmed && trimmed.length > 0 + ? `New member in ${trimmed}` + : "New community member"; +} + +export function joinAlertBody(displayName: string) { + return `${displayName} joined`; +} + +/** + * Most per-key notifications emitted for a single snapshot. + * + * Above this, one summary replaces the batch. A snapshot is a whole roster, not + * an event per join, so a bulk import or an invite link shared into a group + * chat lands every new key at once: without a cap that is one OS notification + * per member (measured: a 250-key snapshot emitted 248 banners in a serial + * loop). The cap is deliberately small — past a handful the individual + * identities are unreadable as notifications anyway, and the useful signal is + * that a batch arrived. + */ +export const JOIN_ALERT_MAX_INDIVIDUAL = 3; + +export function joinAlertSummaryBody(count: number) { + return `${count} new members joined`; +} diff --git a/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs new file mode 100644 index 0000000000..7b34b90161 --- /dev/null +++ b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs @@ -0,0 +1,2236 @@ +/** + * Mounted-hook tests for useCommunityJoinAlerts. + * + * The ledger reducer is covered by lib/joinAlerts.test.mjs. Nothing there + * exercises the parts of this feature that only exist once the hook is + * mounted, and those are exactly the parts a unit test cannot reach: + * + * - the owner/admin gate sitting BEFORE any storage access, so a plain + * member creates no ledger key at all; + * - the reconnect arm, which refetches the snapshot across a socket gap and + * must not re-alert keys the ledger already carries; + * - the effect re-key on community switch, so each community gets its own + * subscription and its own seed state; + * - the kind:8000 arm refetching the authoritative snapshot rather than + * alerting from the delta's own payload. + * + * Max's live-local matrix could not land the reconnect arm (simultaneous + * browser reloads tripped relay rate limiting) and did not exercise community + * switch, so these are the only evidence for those two paths. + * + * ── Harness shape ──────────────────────────────────────────────────────────── + * Same pattern as useLoadArchivedObserverEvents.test.mjs: minimal DOM shim → + * __TAURI_INTERNALS__.invoke interception → production imports → createRoot/act + * inside a QueryClientProvider. relayClient's three entry points are replaced + * with mock.method so no socket is opened; window.Notification is stubbed so + * sendDesktopNotification takes its real permission-granted path and we can + * count what it emitted. + */ + +import assert from "node:assert/strict"; +import { describe, it, beforeEach, afterEach, mock } from "node:test"; + +// ── Minimal DOM shim ───────────────────────────────────────────────────────── + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children[this.children.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.children.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + this.children.splice(i, 0, newNode); + this.childNodes.splice(i, 0, newNode); + newNode.parentNode = this; + return newNode; + } + contains(node) { + if (!node) return false; + return this === node || this.children.some((c) => c?.contains?.(node)); + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + const n = new MinimalNode("#text"); + n.nodeValue = value; + n.nodeType = 3; + return n; + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeValue = value; + n.nodeType = 8; + return n; + } + get body() { + if (!this._body) this._body = this.createElement("body"); + return this._body; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + } + + globalThis.document = new MinimalDocument(); + globalThis.HTMLElement = MinimalNode; + // react-dom's commit phase does `element instanceof window.HTMLIFrameElement` + // (getActiveElementDeep, react-dom-client.development.js:3667). Leaving it + // undefined throws "Right-hand side of 'instanceof' is not an object" out of + // commitRoot, before any assertion runs. + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +} + +installDOMShim(); + +// ── localStorage shim ──────────────────────────────────────────────────────── +// +// Backs the real production ledger read/write. Kept as a plain Map so a test +// can inspect exactly which keys the feature created — the plain-member arm +// asserts on key ABSENCE, so a shim that silently swallows writes would make +// that assertion vacuous. + +const storage = new Map(); +/** When true the shim rejects writes the way a full origin quota does. */ +let storageFull = false; + +globalThis.localStorage = { + get length() { + return storage.size; + }, + key: (index) => [...storage.keys()][index] ?? null, + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => { + if (storageFull) { + const error = new Error("QuotaExceededError"); + error.name = "QuotaExceededError"; + throw error; + } + storage.set(key, value); + }, + removeItem: (key) => storage.delete(key), + clear: () => storage.clear(), +}; +globalThis.window.localStorage = globalThis.localStorage; + +// ── Notification shim ──────────────────────────────────────────────────────── +// +// sendDesktopNotification returns false unless permission is "granted", so +// without this every alert assertion would pass for the wrong reason (silent +// success). Recording the constructor calls is how we count alerts. + +const notifications = []; +/** + * Optional hook fired synchronously from inside the Notification constructor. + * + * The named-alert loop awaits each send, so "a demotion lands between send 1 + * and send 2" is only expressible from inside a send. Nothing else in the + * harness can reach that point in the loop. + */ +let onNotification = null; + +class StubNotification { + static permission = "granted"; + constructor(title, options) { + notifications.push({ title, body: options?.body, options }); + if (onNotification) onNotification(notifications.length); + } + close() {} +} + +globalThis.Notification = StubNotification; +globalThis.window.Notification = StubNotification; + +// ── Tauri IPC interceptor ──────────────────────────────────────────────────── + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +globalThis.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), +}; + +// ── Production imports (after shims) ───────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts.ts"; +import { joinAlertStorageKey } from "@/features/community-members/lib/joinAlerts.ts"; +import { relayClient } from "@/shared/api/relayClient.ts"; +import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; +import { useCommunities } from "@/features/communities/useCommunities.tsx"; +import { + myRelayMembershipLookupQueryKey, + relayMembersQueryKey, + useRelayMembersQuery, +} from "@/features/community-members/hooks.ts"; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const VIEWER = "a".repeat(64); +const ALICE = "b".repeat(64); +const BOB = "c".repeat(64); +const CAROL = "d".repeat(64); +const COMMUNITY_A = "community-a"; +const COMMUNITY_B = "community-b"; + +const KIND_SNAPSHOT = 13534; +const KIND_MEMBER_ADDED = 8000; + +/** + * A kind:13534 membership snapshot carrying the given roster. + * + * The viewer is stamped `owner` unless `viewerRole` says otherwise, mirroring + * the relay: `publish_nip43_membership_locked` emits `["member", pubkey, role]` + * for every row, so the viewer's own authorization always rides in the + * snapshot. A fixture that stamped everyone `member` could not express the + * demotion this hook now gates on. + */ +function snapshot( + rosterPubkeys, + { id = "snap-1", createdAt = 1000, viewerRole = "owner" } = {}, +) { + return { + id, + pubkey: "f".repeat(64), + created_at: createdAt, + kind: KIND_SNAPSHOT, + tags: rosterPubkeys.map((pubkey) => [ + "member", + pubkey, + pubkey === VIEWER ? viewerRole : "member", + ]), + content: "", + sig: "s".repeat(128), + }; +} + +/** Seed the communities the provider will load from localStorage. */ +function seedCommunities(activeId) { + storage.set( + "buzz-communities", + JSON.stringify([ + { + id: COMMUNITY_A, + name: "Community A", + relayUrl: "wss://a.test", + addedAt: "2026-01-01T00:00:00Z", + }, + { + id: COMMUNITY_B, + name: "Community B", + relayUrl: "wss://b.test", + addedAt: "2026-01-01T00:00:00Z", + }, + ]), + ); + storage.set("buzz-active-community-id", activeId); +} + +/** + * Replace relayClient's three entry points and hand the test direct control of + * every callback the hook registers. + */ +function installRelayStub() { + /** @type {Map void>>} */ + const liveByKind = new Map(); + const reconnectListeners = []; + let fetchFirstEventCalls = 0; + let nextSnapshot = null; + let subscribeCount = 0; + let unsubscribeCount = 0; + /** When set, `fetchFirstEvent` parks here before resolving. */ + let fetchGate = null; + + mock.method(relayClient, "subscribeLive", async (filter, onEvent) => { + subscribeCount++; + const kind = filter.kinds[0]; + if (!liveByKind.has(kind)) liveByKind.set(kind, []); + liveByKind.get(kind).push(onEvent); + return async () => { + unsubscribeCount++; + const list = liveByKind.get(kind) ?? []; + liveByKind.set( + kind, + list.filter((fn) => fn !== onEvent), + ); + }; + }); + + mock.method(relayClient, "fetchFirstEvent", async () => { + fetchFirstEventCalls++; + if (fetchGate) await fetchGate; + return nextSnapshot; + }); + + mock.method(relayClient, "subscribeToReconnects", (listener) => { + reconnectListeners.push(listener); + return () => { + const i = reconnectListeners.indexOf(listener); + if (i >= 0) reconnectListeners.splice(i, 1); + }; + }); + + return { + /** Deliver a snapshot down every live kind:13534 callback. */ + emitSnapshot: (event) => { + for (const fn of liveByKind.get(KIND_SNAPSHOT) ?? []) fn(event); + }, + /** Deliver a kind:8000 delta down every live accelerator callback. */ + emitDelta: (event) => { + for (const fn of liveByKind.get(KIND_MEMBER_ADDED) ?? []) fn(event); + }, + /** Fire the relay client's reconnect notification. */ + emitReconnect: () => { + for (const fn of [...reconnectListeners]) fn(); + }, + /** What a subsequent fetchFirstEvent (refetch) resolves to. */ + setRefetchSnapshot: (event) => { + nextSnapshot = event; + }, + /** + * Hold the snapshot refetch open, the way a slow relay does. + * + * The stale-frame privacy race is only expressible if a refetch can resolve + * AFTER a newer live frame has been processed. Without a gate here, the + * refetch resolves inside the same drain that started it and the two frames + * can never be interleaved. + */ + deferRefetch: () => { + let release = null; + fetchGate = new Promise((resolve) => { + release = resolve; + }); + return async () => { + fetchGate = null; + release(); + await settle(); + }; + }, + counts: () => ({ + fetchFirstEventCalls, + subscribeCount, + unsubscribeCount, + liveSnapshotSubs: (liveByKind.get(KIND_SNAPSHOT) ?? []).length, + liveDeltaSubs: (liveByKind.get(KIND_MEMBER_ADDED) ?? []).length, + reconnectListeners: reconnectListeners.length, + }), + }; +} + +/** Mount the real hook under a real CommunitiesProvider + QueryClientProvider. */ +function mountHook({ role = "owner", enabled = true } = {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(["identity"], { pubkey: VIEWER }); + queryClient.setQueryData(myRelayMembershipLookupQueryKey, { + snapshotFound: true, + membershipRequired: true, + membership: + role === null + ? null + : { pubkey: VIEWER, role, addedBy: null, createdAt: null }, + }); + + const invalidations = []; + const realInvalidate = queryClient.invalidateQueries.bind(queryClient); + queryClient.invalidateQueries = (args) => { + invalidations.push(args?.queryKey); + return realInvalidate(args); + }; + + // Captured from inside the tree so a test can switch community the way the + // rail does — in the SAME mounted tree. Unmount/remount would tear the + // subscriptions down no matter what the effect keys on, which makes the + // re-key assertion pass on a hook with an empty dependency array. + const control = { switchCommunity: null }; + + function Harness() { + control.switchCommunity = useCommunities().switchCommunity; + useCommunityJoinAlerts({ enabled }); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + + const render = async () => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Harness, null), + ), + ), + ); + }); + }; + + return { + render, + invalidations, + queryClient, + switchCommunity: async (id) => { + await act(async () => { + control.switchCommunity(id); + }); + }, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +async function settle(iterations = 4) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 5)); + }); + } +} + +/** + * Advance past the kind:8000 refetch debounce, then settle. + * + * The accelerator coalesces refetches on a 500ms trailing window so a bulk add + * costs one REQ instead of one per member; anything asserting on a refetch has + * to outwait that window or it is asserting on a timer that has not fired. + */ +async function settleAfterRefreshDebounce() { + await act(async () => { + await new Promise((r) => setTimeout(r, 600)); + }); + await settle(); +} + +/** + * Advance past the cross-snapshot notify window, then settle. + * + * Alerts are queued per snapshot and delivered on a trailing quiet window, so + * a burst spanning several intermediate 13534s produces one notification + * instead of one per snapshot. Anything asserting that a notification WAS + * delivered has to outwait that window; anything asserting an absence should + * outwait it too, or it proves only that delivery is deferred. + */ +async function settleAfterNotifyWindow() { + await act(async () => { + await new Promise((r) => setTimeout(r, 1_700)); + }); + await settle(); +} + +/** + * Hold the profile lookup open so the timer-fired/lookup-in-flight window is + * addressable from a test. + * + * `flushPending` consumes the pending refs at entry and then awaits + * `getUsersBatch` before it sends anything. Every arm that wants to assert on + * a revocation arriving DURING a flush has to be able to park the flush there; + * without this, the whole flush runs inside one microtask drain and the + * ordering Max and Wren found is not expressible at all. + * + * Routed through the Tauri IPC shim rather than a module mock so the real + * `getUsersBatch` runs — a stubbed production function would be a fixture + * re-declaring the code under test. + */ +function deferProfileLookup() { + let release = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + ipcHandlers.set("get_users_batch", async () => { + calls += 1; + await gate; + return { profiles: {}, missing: [] }; + }); + return { + calls: () => calls, + /** Let the in-flight lookup resolve, then drain. */ + release: async () => { + release(); + await settle(); + }, + }; +} + +function ledgerKeys() { + return [...storage.keys()].filter((key) => + key.startsWith("buzz-community-join-seen.v1"), + ); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("useCommunityJoinAlerts — mounted subscription behaviour", () => { + beforeEach(() => { + storage.clear(); + storageFull = false; + notifications.length = 0; + onNotification = null; + ipcHandlers.clear(); + seedCommunities(COMMUNITY_A); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + /** + * Positive control for the whole harness. Every other arm asserts an absence + * (no alert, no key, no extra subscription); if the harness could never + * produce an alert in the first place, all of them would pass vacuously. + */ + it("seeds silently on the first snapshot, then alerts on a genuine join", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + assert.equal( + notifications.length, + 0, + "the first snapshot per community must seed silently", + ); + assert.equal(ledgerKeys().length, 1, "the seed must be persisted"); + + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB], { id: "snap-2" })); + await settleAfterNotifyWindow(); + + assert.equal(notifications.length, 1, "a genuine join must alert once"); + assert.match(notifications[0].title, /Community A/); + assert.match(notifications[0].body, /joined/); + + await unmount(); + }); + + /** + * A plain member mounts the hook (it is mounted unconditionally alongside the + * other desktop notification wiring) and must be inert. Eva asked for the + * stronger assertion: not merely "no notification" but "no ledger key", which + * proves the role gate sits before storage access rather than after it. + * + * A key materializing here would not be a gate-ordering nit — it would mean + * canManageCommunityMembers returned true for a non-manager, i.e. a + * role-resolution bug upstream in relayMembers.ts. + */ + it("is completely inert for a plain member: no subscription, no ledger key", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "member" }); + + await render(); + await settle(); + + const counts = relay.counts(); + assert.equal( + counts.subscribeCount, + 0, + "a plain member must open no subscription", + ); + assert.equal( + counts.reconnectListeners, + 0, + "a plain member must register no reconnect listener", + ); + + // Even if a snapshot somehow arrived, nothing is wired to receive it. + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB])); + await settle(); + + assert.equal(notifications.length, 0); + assert.deepEqual( + ledgerKeys(), + [], + "no buzz-community-join-seen.v1 key may be created for a plain member", + ); + + await unmount(); + }); + + /** + * `enabled: false` is the desktopEnabled precondition from + * useAppShellDesktopNotifications. An owner with notifications switched off + * must be as inert as a plain member — including writing no ledger, so + * turning notifications back on later seeds rather than back-alerting. + */ + it("is inert for an owner when notifications are disabled", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ enabled: false }); + + await render(); + await settle(); + + assert.equal(relay.counts().subscribeCount, 0); + assert.deepEqual(ledgerKeys(), []); + + await unmount(); + }); + + /** + * Reconnect arm. Max could not land this live (simultaneous browser reloads + * tripped relay rate limiting), so this is the only evidence for it. + * + * Two halves, and the second is the one that matters: the reconnect must + * refetch (a socket gap can span joins that `limit: 1` backfill will not + * redeliver), AND the refetched snapshot must not re-alert keys the ledger + * already carries. Asserting only the refetch would pass on a hook that + * alerts twice for every reconnect. + */ + it("refetches on reconnect without re-alerting already-seen keys", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB], { id: "snap-2" })); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "precondition: one join alerted"); + + const before = relay.counts().fetchFirstEventCalls; + + // The socket drops and recovers; the relay client replays the same roster. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-replay", createdAt: 2000 }), + ); + relay.emitReconnect(); + await settleAfterRefreshDebounce(); + await settleAfterNotifyWindow(); + + assert.ok( + relay.counts().fetchFirstEventCalls > before, + "reconnect must refetch the authoritative snapshot", + ); + assert.equal( + notifications.length, + 1, + "a reconnect replay of a known roster must not re-alert", + ); + + // A key that joined during the gap still alerts on the refetched snapshot. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB, "d".repeat(64)], { + id: "snap-gap", + createdAt: 3000, + }), + ); + relay.emitReconnect(); + await settleAfterRefreshDebounce(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 2, + "a join that landed during the socket gap must alert on refetch", + ); + + await unmount(); + }); + + /** + * The kind:8000 accelerator must refetch the authoritative snapshot rather + * than alert from the delta's own payload — that is what lets one ledger + * govern both signals so the pair cannot double-alert. + * + * The delta here names a pubkey that is NOT in the refetched roster. A hook + * alerting off the delta payload would fire; the correct hook fires nothing, + * because the snapshot is the authority. + */ + it("treats kind:8000 as a refetch trigger, not an alert payload", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + assert.equal(notifications.length, 0); + + const before = relay.counts().fetchFirstEventCalls; + + // Delta names a pubkey the authoritative roster does not (yet) carry. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE], { id: "snap-unchanged", createdAt: 2000 }), + ); + relay.emitDelta({ + id: "delta-1", + pubkey: "f".repeat(64), + created_at: 1500, + kind: KIND_MEMBER_ADDED, + tags: [["p", BOB]], + content: "", + sig: "s".repeat(128), + }); + await settleAfterRefreshDebounce(); + + assert.ok( + relay.counts().fetchFirstEventCalls > before, + "a kind:8000 delta must trigger a snapshot refetch", + ); + assert.equal( + notifications.length, + 0, + "the delta's own payload must never produce an alert — only the snapshot decides", + ); + + // Now the snapshot agrees, and exactly one alert follows. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-agrees", createdAt: 3000 }), + ); + relay.emitDelta({ + id: "delta-2", + pubkey: "f".repeat(64), + created_at: 2500, + kind: KIND_MEMBER_ADDED, + tags: [["p", BOB]], + content: "", + sig: "s".repeat(128), + }); + await settleAfterRefreshDebounce(); + await settleAfterNotifyWindow(); + + assert.equal(notifications.length, 1); + + // And the live snapshot carrying the same join must not alert a second time. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-live", createdAt: 3500 }), + ); + await settle(); + assert.equal( + notifications.length, + 1, + "the accelerator and the live snapshot share one ledger and must not double-alert", + ); + + await unmount(); + }); + + /** + * Community switch. Max's live-local run did not exercise this. + * + * The switch happens in the SAME mounted tree (via the provider's real + * switchCommunity), not by remounting: a remount tears every subscription + * down regardless of what the effect keys on, so a remount-based version of + * this test would pass on a hook with an empty dependency array. Switching + * in-tree makes the assertion actually about [active, communityId, viewer]. + */ + it("re-keys on community switch: fresh subscription and independent seed", async () => { + const relay = installRelayStub(); + const harness = mountHook(); + + await harness.render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB], { id: "snap-2" })); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "precondition: A alerted once"); + assert.deepEqual(ledgerKeys(), [joinAlertStorageKey(COMMUNITY_A, VIEWER)]); + + const beforeSwitch = relay.counts(); + assert.equal( + beforeSwitch.liveSnapshotSubs, + 1, + "precondition: A holds one live snapshot subscription", + ); + + await harness.switchCommunity(COMMUNITY_B); + await settle(); + + const afterSwitch = relay.counts(); + assert.equal( + afterSwitch.unsubscribeCount, + beforeSwitch.subscribeCount, + `switching must close every subscription community A opened — opened ${beforeSwitch.subscribeCount}, closed ${afterSwitch.unsubscribeCount}`, + ); + assert.equal( + afterSwitch.subscribeCount, + beforeSwitch.subscribeCount * 2, + "switching must open a fresh pair of subscriptions for community B", + ); + assert.equal( + afterSwitch.liveSnapshotSubs, + 1, + "exactly one live snapshot subscription may be open after the switch", + ); + assert.equal( + afterSwitch.liveDeltaSubs, + 1, + "exactly one live delta subscription may be open after the switch", + ); + + // B's existing roster must seed silently even though A is already seeded. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-b", createdAt: 4000 }), + ); + await settle(); + + assert.equal( + notifications.length, + 1, + "community B must seed silently — its roster is not a set of joins", + ); + + const keys = ledgerKeys().sort(); + assert.deepEqual( + keys, + [ + joinAlertStorageKey(COMMUNITY_A, VIEWER), + joinAlertStorageKey(COMMUNITY_B, VIEWER), + ].sort(), + "each community must keep its own ledger", + ); + + // And B alerts on its own first genuine join. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, "d".repeat(64)], { + id: "snap-b2", + createdAt: 5000, + }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 2); + + // Switching back must not re-alert A's roster: its ledger persisted. + await harness.switchCommunity(COMMUNITY_A); + await settle(); + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-a-return", createdAt: 6000 }), + ); + await settleAfterNotifyWindow(); + assert.equal( + notifications.length, + 2, + "returning to A must not re-alert keys A's ledger already carries", + ); + + await harness.unmount(); + }); + + /** + * Eva's red-team finding (thread 866f149d): writeJoinAlertLedger returns + * whether the write landed, and the caller dropped it. On a quota failure + * that survives cache eviction the alert fired against an unpersisted + * ledger — so the next reload re-alerted the same keys, which is exactly the + * "repeat" the ordering comment one line above promises never to do. + * + * Two halves, and both are needed. Asserting only "no notification" would + * pass on a hook that also poisons the in-memory ref, silently swallowing + * the alert forever. The second half proves the alert is deferred, not lost: + * once storage recovers, the next snapshot delivers it. + */ + it("does not notify when the ledger write cannot land, and delivers once it can", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + assert.equal(ledgerKeys().length, 1, "precondition: the seed persisted"); + + // Origin quota is exhausted and cache eviction cannot free enough. + storageFull = true; + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB], { id: "snap-full" })); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "an alert must not fire against a ledger that was never persisted", + ); + + // Storage recovers. The same join must still be pending, not consumed by + // the failed attempt: the ref was deliberately left un-advanced. + storageFull = false; + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-recovered", createdAt: 2000 }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "the deferred alert must be delivered by the first snapshot whose write lands", + ); + + // And it is not delivered twice now that the ledger is on disk. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-after", createdAt: 3000 }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1); + + await unmount(); + }); + + /** + * A snapshot refreshes the members panel regardless of alert eligibility: a + * removal or a role change alters the roster without producing anything new + * to alert on, and the open panel must still repaint. + * + * The refresh is a direct cache WRITE, not an invalidation — an invalidation + * refetched every active observer, costing one REQ frame per snapshot (see + * the two-arm REQ test at the end of this file). So this asserts the roster + * that lands in the cache, which is the property the panel actually renders + * from, and is a strictly stronger claim than "an invalidation was issued": + * it fails both if the refresh disappears AND if it writes the wrong roster. + */ + it("writes the roster into the members query on every snapshot, including a seeding one", async () => { + const relay = installRelayStub(); + const { render, queryClient, unmount } = mountHook(); + + await render(); + await settle(); + + assert.equal( + queryClient.getQueryData(relayMembersQueryKey), + undefined, + "precondition: nothing has populated the members query yet", + ); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + const cached = queryClient.getQueryData(relayMembersQueryKey); + assert.deepEqual( + cached?.map((member) => member.pubkey).sort(), + [VIEWER, ALICE].sort(), + "the seeding snapshot must still refresh the roster panel", + ); + // The viewer's own role rides in the snapshot, so the written rows carry it + // — a fixture writing bare pubkeys would render an owner as a plain member. + assert.equal( + cached?.find((member) => member.pubkey === VIEWER)?.role, + "owner", + "the written rows must carry roles, not just pubkeys", + ); + + await unmount(); + }); + /** + * Authorization must come from the snapshot in hand, not the cached role that + * mounted the effect. + * + * `useMyRelayMembershipLookupQuery` is invalidated only by this client's own + * membership mutations, and `staleTime` marks data stale without scheduling a + * refetch — so a viewer demoted by ANOTHER admin keeps a cached owner/admin + * role for as long as the app stays open. Found by Wren, reproduced live by + * Max against a real relay: the demoted viewer kept learning every later + * joiner's identity. + * + * The demotion and the join ride in the SAME snapshot, which is the racy + * shape: an async invalidation cannot beat the handler it is racing. + */ + it("stops alerting when the snapshot itself demotes the viewer", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { viewerRole: "admin" })); + await settle(); + + // Positive control: still admin, so a genuine join must alert. Without + // this, a gate that refused everything would pass the assertions below. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-join", viewerRole: "admin" }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "precondition: admin still alerts"); + + const ledgerBefore = storage.get(joinAlertStorageKey(COMMUNITY_A, VIEWER)); + + // Remote demotion + a new member, in one authoritative snapshot. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, CAROL], { + id: "snap-demote", + createdAt: 4000, + viewerRole: "member", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "a demoted viewer must not be told who joined", + ); + assert.equal( + storage.get(joinAlertStorageKey(COMMUNITY_A, VIEWER)), + ledgerBefore, + "the ledger must not advance on a snapshot the viewer is not authorized for", + ); + + await unmount(); + }); + + /** + * Removal is the same disclosure as demotion, and `find` returning undefined + * is a different code path from a role that is present but wrong. + */ + it("stops alerting when the viewer is dropped from the roster entirely", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + // Viewer absent from the snapshot; a new key arrives alongside. + relay.emitSnapshot({ + id: "snap-removed", + pubkey: "f".repeat(64), + created_at: 5000, + kind: KIND_SNAPSHOT, + tags: [ + ["member", ALICE, "member"], + ["member", BOB, "member"], + ], + content: "", + sig: "s".repeat(128), + }); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a removed viewer must learn nothing about later joins", + ); + + await unmount(); + }); + + /** + * A snapshot is a whole roster, so a bulk add lands every new key at once. + * Uncapped that is one OS notification per member — measured at 248 banners + * for a 250-key snapshot, delivered through a serial await loop. + */ + it("collapses a bulk join into one summary instead of a banner per member", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + const bulk = []; + for (let i = 0; i < 40; i++) { + bulk.push(`${i.toString(16).padStart(2, "0").repeat(31)}ff`); + } + relay.emitSnapshot( + snapshot([VIEWER, ALICE, ...bulk], { id: "snap-bulk", createdAt: 6000 }), + ); + await settleAfterNotifyWindow(); + + assert.equal(notifications.length, 1, "one summary, not one per member"); + assert.equal(notifications[0].body, "40 new members joined"); + + await unmount(); + }); + + /** + * Below the cap the alert still names people — the summary must not swallow + * the ordinary one-or-two-join case the feature exists for. + */ + it("still names individuals for a small batch", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, CAROL], { + id: "snap-two", + createdAt: 7000, + }), + ); + await settleAfterNotifyWindow(); + + assert.equal(notifications.length, 2, "two joins, two named alerts"); + assert.ok( + notifications.every((entry) => entry.body.endsWith(" joined")), + "each alert names the joiner rather than summarizing", + ); + + await unmount(); + }); + + /** + * Each refetch is a REQ frame billed against the same per-principal WsEvents + * budget as the user's own sends (default 10/s over a 5s window), and a bulk + * add emits one kind:8000 per member. Uncoalesced that was 250 REQs for 250 + * deltas — spending the budget the owner needs to send messages and open + * channels. + */ + it("coalesces a burst of kind:8000 deltas into a single refetch", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + const before = relay.counts().fetchFirstEventCalls; + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE], { id: "snap-burst", createdAt: 8000 }), + ); + + for (let i = 0; i < 50; i++) { + relay.emitDelta({ + id: `burst-${i}`, + pubkey: "f".repeat(64), + created_at: 8000 + i, + kind: KIND_MEMBER_ADDED, + tags: [["p", BOB]], + content: "", + sig: "s".repeat(128), + }); + } + await settleAfterRefreshDebounce(); + + assert.equal( + relay.counts().fetchFirstEventCalls - before, + 1, + "50 deltas must cost exactly one REQ, not 50", + ); + + await unmount(); + }); + + /** + * Max's live 50-join storm at `fdeda44f0`: 10 banners, not 1. + * + * The per-snapshot cap answers "one snapshot, many keys". The relay answers + * back "one burst, many snapshots" — it republishes the whole 13534 as each + * concurrent add commits, so a storm arrives as several growing rosters and + * each one independently emitted its own capped batch. The batch sizes below + * are Max's observed live values (6, 17, 4, 4, 4, 4, 11 = 50). + */ + it("collapses a burst spanning several snapshots into one alert", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER])); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 0, "precondition: seeded silently"); + + const roster = [VIEWER]; + let minted = 0; + let snapIndex = 0; + for (const size of [6, 17, 4, 4, 4, 4, 11]) { + for (let i = 0; i < size; i++) { + minted += 1; + roster.push(minted.toString(16).padStart(2, "0").repeat(32)); + } + snapIndex += 1; + relay.emitSnapshot( + snapshot([...roster], { + id: `storm-${snapIndex}`, + createdAt: 9000 + snapIndex, + }), + ); + await settle(); + } + await settleAfterNotifyWindow(); + + assert.equal(minted, 50, "fixture must mint Max's 50 joins"); + assert.equal( + notifications.length, + 1, + "a burst spanning 7 snapshots must produce one alert, not one per snapshot", + ); + assert.equal(notifications[0].body, "50 new members joined"); + + await unmount(); + }); + + /** + * Wren's arm 3, and the reason batching is not free: deferring delivery + * reopens his disclosure as a DELAYED one unless revocation also drops what + * is already queued. Measured failing before the clearPending() call existed + * — the queued batch flushed "5 new members joined" after the demotion. + */ + it("drops queued alerts when a later snapshot demotes the viewer", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER], { viewerRole: "admin" })); + await settle(); + + // Joins land and are queued, but the flush window has not elapsed. + const roster = [VIEWER, ALICE, BOB, CAROL]; + relay.emitSnapshot( + snapshot([...roster], { + id: "queued-joins", + createdAt: 10_000, + viewerRole: "admin", + }), + ); + await settle(); + assert.equal( + notifications.length, + 0, + "precondition: delivery is still pending on the window", + ); + + // Demotion arrives before the timer fires. + relay.emitSnapshot( + snapshot([...roster], { + id: "queued-demote", + createdAt: 10_001, + viewerRole: "member", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a demotion before the flush must cancel the queued batch, not delay it", + ); + + await unmount(); + }); + + /** + * Wren's arm 5. The window must batch a burst without swallowing legitimate + * later joins — otherwise the fix trades 10 spurious alerts for a silently + * dropped one. + */ + it("still alerts separately for joins beyond the batching window", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER])); + await settleAfterNotifyWindow(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { id: "join-1", createdAt: 11_000 }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "first join alerts on its own"); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "join-2", createdAt: 12_000 }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 2, + "a join after the window closed must get its own alert, not be suppressed", + ); + + await unmount(); + }); + + /** + * Teardown must drop the queued batch, not just its timer. On a community + * switch the effect re-keys, and keys accumulated for the old community must + * never flush against the new one. + */ + it("does not deliver a queued batch after unmount", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER])); + await settle(); + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, CAROL], { + id: "queued-at-teardown", + createdAt: 13_000, + }), + ); + await settle(); + assert.equal(notifications.length, 0, "precondition: still queued"); + + await unmount(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a torn-down mount must not fire its pending batch", + ); + }); + + // ── Mid-flight cancellation (Max's race, Wren's arm list) ────────────────── + // + // Clearing the pending refs cannot stop a flush that already consumed them. + // Every send sits behind an await — the profile lookup, then each + // notification — so a revocation landing after the timer fired but before + // the sends resolve delivered anyway at 5d0d2b4c. These arms pin the + // generation token that closes it. All five park the flush on a deferred + // `get_users_batch`; without that the ordering is not expressible. + + it("suppresses an in-flight flush when a demotion lands during the lookup", async () => { + const relay = installRelayStub(); + const profiles = deferProfileLookup(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER], { viewerRole: "admin" })); + await settle(); + + const roster = [VIEWER, ALICE, BOB]; + relay.emitSnapshot( + snapshot([...roster], { + id: "inflight-joins", + createdAt: 14_000, + viewerRole: "admin", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + profiles.calls(), + 1, + "precondition: flush is parked on the lookup", + ); + assert.equal(notifications.length, 0, "precondition: nothing sent yet"); + + // Authorization is revoked while the flush holds the batch in locals. + relay.emitSnapshot( + snapshot([...roster], { + id: "inflight-demote", + createdAt: 14_001, + viewerRole: "member", + }), + ); + await settle(); + + await profiles.release(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a demotion during the profile lookup must abort the resumed flush", + ); + + await unmount(); + }); + + it("suppresses an in-flight flush when the viewer is removed during the lookup", async () => { + const relay = installRelayStub(); + const profiles = deferProfileLookup(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER], { viewerRole: "admin" })); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "removal-joins", + createdAt: 15_000, + viewerRole: "admin", + }), + ); + await settleAfterNotifyWindow(); + assert.equal( + profiles.calls(), + 1, + "precondition: flush is parked on the lookup", + ); + + // Dropped from the roster entirely — fail closed, same as a demotion. + relay.emitSnapshot( + snapshot([ALICE, BOB], { id: "removal", createdAt: 15_001 }), + ); + await settle(); + + await profiles.release(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "removal during the profile lookup must abort the resumed flush", + ); + + await unmount(); + }); + + it("suppresses an in-flight flush across a community switch, under either name", async () => { + const relay = installRelayStub(); + const profiles = deferProfileLookup(); + const { render, switchCommunity, unmount } = mountHook(); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER])); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "switch-joins", + createdAt: 16_000, + }), + ); + await settleAfterNotifyWindow(); + assert.equal( + profiles.calls(), + 1, + "precondition: flush is parked on the lookup", + ); + + await switchCommunity(COMMUNITY_B); + await profiles.release(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "community A's keys must not deliver after the switch to B", + ); + // The title is read at send time from a ref, so a surviving flush would + // also mislabel A's joiners as B's. Assert the mislabel is impossible + // rather than inferring it from the count above. + assert.ok( + notifications.every((entry) => !entry.title.includes("Community B")), + "no alert may carry the new community's title", + ); + + await unmount(); + }); + + it("suppresses the remainder of a batch when a demotion lands between sends", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER], { viewerRole: "admin" })); + await settle(); + + const roster = [VIEWER, ALICE, BOB, CAROL]; + relay.emitSnapshot( + snapshot([...roster], { + id: "midloop-joins", + createdAt: 17_000, + viewerRole: "admin", + }), + ); + await settle(); + + // Fire the demotion from inside the first send — the only point in the + // program where "between named send 1 and send 2" exists. + onNotification = (count) => { + if (count !== 1) return; + onNotification = null; + relay.emitSnapshot( + snapshot([...roster], { + id: "midloop-demote", + createdAt: 17_001, + viewerRole: "member", + }), + ); + }; + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "the send already in flight completes, but the rest of the batch is suppressed", + ); + + await unmount(); + }); + + /** + * Positive control for the cancellation token, and the semantics Eva asked + * to be pinned: the generation bumps on CANCELLATION only, never on an + * ordinary enqueue. A newer authorized batch queued while an earlier flush's + * lookup is in flight must neither cancel it nor be cancelled by it — both + * deliver. + * + * Without this arm a token that bumped on every enqueue would pass all four + * arms above by suppressing everything, which is the failure mode a + * suppression test cannot see. + */ + it("delivers both batches when a new authorized batch queues during a flush", async () => { + const relay = installRelayStub(); + const profiles = deferProfileLookup(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER])); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { id: "batch-one", createdAt: 18_000 }), + ); + await settleAfterNotifyWindow(); + assert.equal( + profiles.calls(), + 1, + "precondition: first flush parked on the lookup", + ); + assert.equal(notifications.length, 0, "precondition: nothing sent yet"); + + // A second, fully authorized join arrives while the first flush waits. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "batch-two", createdAt: 18_001 }), + ); + await settle(); + + await profiles.release(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 2, + "a legitimate concurrent batch must not erase, or be erased by, the in-flight one", + ); + assert.ok( + notifications.every((entry) => entry.body.endsWith(" joined")), + "both alerts name their joiner", + ); + + await unmount(); + }); + + // ── Stale-frame ordering: the fence and the revocation latch ─────────────── + // + // Everything below concerns frames arriving out of order. The demotion arms + // above all deliver the revoking snapshot LAST, which is the only ordering a + // trailing-window suite naturally produces — and the ordering under which a + // hook with no fence and no latch passes every one of them. + + /** + * The privacy regression. Red at 0cfe4832, green with the latch. + * + * A refetch (kind:8000 accelerator or reconnect) is held open while a newer + * live frame demotes the viewer. The stale frame then resolves still listing + * the viewer as owner AND carrying a new member. At 0cfe4832 the hook + * authorized that frame against its own roster, found "owner", and disclosed + * the joiner's identity to an admin who had already been demoted — measured as + * `notifications=1 bodies=["cccc… joined"]`. + * + * The latch is what closes it, not the fence: `created_at` ordering alone + * cannot, because the relay can emit two snapshots in the same second. + */ + it("never discloses a joiner from a stale frame that outlives a demotion", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + assert.equal(notifications.length, 0, "precondition: seeded silently"); + + // A stale authorized frame — still owner, and it carries BOB — is put in + // flight and held there. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-stale-authorized", + createdAt: 20_000, + }), + ); + const releaseRefetch = relay.deferRefetch(); + relay.emitDelta({ + id: "delta-1", + pubkey: "f".repeat(64), + created_at: 20_000, + kind: KIND_MEMBER_ADDED, + tags: [["p", BOB]], + content: "", + sig: "s".repeat(128), + }); + await settleAfterRefreshDebounce(); + + // Meanwhile the live subscription delivers the demotion. Same second as the + // stale frame on purpose: a strictly-older fence does not reject it, so this + // arm cannot pass on the fence alone. + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { + id: "snap-demote", + createdAt: 20_000, + viewerRole: "member", + }), + ); + await settle(); + assert.equal( + notifications.length, + 0, + "precondition: the demotion itself discloses nothing", + ); + + // Persisted ledger immediately before the delayed frame is released. The + // notification count alone cannot distinguish "refused before touching the + // ledger" from "recorded BOB as seen but suppressed the banner" — and the + // second shape would silently swallow the alert forever once the session + // recovers, since a key already marked seen is never announced again. + const ledgerBeforeRelease = storage.get( + joinAlertStorageKey(COMMUNITY_A, VIEWER), + ); + + // Now the stale authorized frame lands. + await releaseRefetch(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a frame that predates the demotion must not re-open disclosure", + ); + assert.ok( + !notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + "the demoted viewer must never learn the new member's identity", + ); + assert.equal( + storage.get(joinAlertStorageKey(COMMUNITY_A, VIEWER)), + ledgerBeforeRelease, + "the latched session must refuse the frame before reconciliation, leaving the ledger untouched", + ); + assert.ok( + !(ledgerBeforeRelease ?? "").includes(BOB), + "control: BOB must not already be in the ledger, or the assertion above is vacuous", + ); + + await unmount(); + }); + + /** + * The fence's own arm: a strictly older frame is not treated as current. + * + * Distinct from the latch above — here the viewer is never demoted, so the + * latch never trips and only the `created_at` comparison can reject the frame. + * A stale roster that has LOST a member must not cause that member to be + * re-alerted when they reappear in the (already-seen) newer roster. + */ + it("ignores a strictly older snapshot rather than treating it as current", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { createdAt: 30_000 })); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-new", createdAt: 31_000 }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "precondition: BOB alerted once"); + + const ledgerAfterBob = storage.get( + joinAlertStorageKey(COMMUNITY_A, VIEWER), + ); + + // An older frame arrives late, carrying a roster that predates BOB and adds + // CAROL. Processing it as current would fold a superseded roster in. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, CAROL], { + id: "snap-older", + createdAt: 30_500, + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "an older frame must not alert from a superseded roster", + ); + assert.equal( + storage.get(joinAlertStorageKey(COMMUNITY_A, VIEWER)), + ledgerAfterBob, + "an older frame must not advance the ledger", + ); + + await unmount(); + }); + + /** + * Eva's constraint: the fence advances only on frames actually accepted. + * + * If a rejected frame moved newest-seen, a stale frame could push the fence + * past a legitimate frame still in flight and that real snapshot would be + * dropped as though it were stale. The rejection used here is the empty + * roster, and the legitimate frame that follows carries a LOWER `created_at` + * than the rejected one. + * + * Scope, measured rather than assumed. Moving the empty-roster guard to AFTER + * the fence advance fails this arm and only this arm (28/29 still pass), so it + * is a real and uniquely-targeted guard. But moving the fence advance itself + * back up to the comparison — the literal edit Eva's constraint forbids — + * SURVIVES the whole suite, and that is not a gap in this test: it is an + * equivalent mutant. Only two guards sit between the comparison and the + * advance, and each is already immune: + * + * - the empty-roster guard returns BEFORE the comparison, so a frame it + * rejects never reaches either position; + * - the authorization guard latches `revoked` on the way out, and a revoked + * session refuses every later frame outright, so whether that frame moved + * the fence first is unobservable. + * + * The placement is therefore defence in depth against a FUTURE reject-and- + * continue path, not a currently-reachable defect. Pinning it here is what + * makes the next such guard visible — a new early return added between these + * two points would be caught by this arm rather than by a user. + */ + it("does not advance the stale-frame fence on a frame it rejects", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { createdAt: 40_000 })); + await settle(); + + // Rejected frame, far in the future. An empty roster is dropped before the + // fence would have anything to say about it. + relay.emitSnapshot(snapshot([], { id: "snap-empty", createdAt: 90_000 })); + await settle(); + + // A legitimate frame, newer than the accepted one but OLDER than the + // rejected one. If the rejected frame had advanced the fence, this real + // join would be silently discarded. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-real", createdAt: 41_000 }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "a rejected frame must not fence out a later legitimate one", + ); + + await unmount(); + }); + + /** + * Eva's constraint: the latch trip drops the queued batch before anything + * else, exactly as the pre-latch demotion path did. + * + * The latch is an addition to that path, not a replacement for it, and a latch + * that returned early WITHOUT clearing would leave an armed timer holding + * authorized-at-queue-time keys that fires after revocation. Asserted by + * queueing a batch, tripping the latch mid-window, and then outwaiting the + * window: silence can only come from the batch having been dropped. + */ + it("drops the queued batch when the latch trips, not merely afterwards", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { createdAt: 50_000 })); + await settle(); + + // Queue a batch and leave it pending inside the trailing window. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-queue", createdAt: 51_000 }), + ); + await settle(); + assert.equal( + notifications.length, + 0, + "precondition: the batch is queued, not yet delivered", + ); + + // Trip the latch while that timer is still armed. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-latch", + createdAt: 52_000, + viewerRole: "member", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a batch queued before revocation must be dropped by the latch trip", + ); + + await unmount(); + }); + + /** + * Known and accepted for v1 (Eva's ruling): a stale DEMOTING frame latches a + * viewer who is still a genuine admin, and the latch does not self-clear. + * + * This is the reverse ordering of the privacy race. The invalidation the latch + * fires refetches the membership lookup, which correctly returns admin, so + * `active` stays true, the effect deps do not change, and no re-key occurs — + * the session stays latched until reload or community switch. + * + * It is fail-safe (under-notify, never over-disclose) and consistent with the + * promotion-on-reload semantics this feature already ships, so it is pinned + * here as documented behaviour rather than left to be rediscovered as a bug. + * Clearing it would cost a third piece of timing state, which is not worth it + * at v1. + */ + it("stays latched after a stale demoting frame, until reload or switch (accepted)", async () => { + const relay = installRelayStub(); + const { render, switchCommunity, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { createdAt: 60_000, viewerRole: "admin" }), + ); + await settle(); + + // A stale frame that does not list the viewer as a manager arrives first. + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { + id: "snap-stale-demote", + createdAt: 60_000, + viewerRole: "member", + }), + ); + await settle(); + + // The viewer is in fact still an admin, and later frames say so. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-still-admin", + createdAt: 61_000, + viewerRole: "admin", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "documented: the latch does not self-clear, so alerts stay off for this session", + ); + + // A community switch re-keys the effect and builds a fresh session, which is + // the documented recovery path (alongside reload). Switching away and back + // is what a user does; assert the feature is alive again afterwards. + await switchCommunity(COMMUNITY_B); + await settle(); + await switchCommunity(COMMUNITY_A); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-after-switch", + createdAt: 62_000, + viewerRole: "admin", + }), + ); + await settle(); + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, CAROL], { + id: "snap-after-switch-join", + createdAt: 63_000, + viewerRole: "admin", + }), + ); + await settleAfterNotifyWindow(); + + // Two, not one: the latched session suppressed BOB's alert but also never + // recorded him in the ledger, so the fresh session sees him as unseen and + // announces him alongside CAROL. The accepted cost of the latch is therefore + // DELAYED notification, not lost notification — which is what makes + // "fail-safe" true rather than merely reassuring. + assert.equal( + notifications.length, + 2, + "a community switch clears the latch: the feature recovers without a reload", + ); + assert.ok( + notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + "the join suppressed by the latch is re-announced, not lost", + ); + assert.ok( + notifications.some((entry) => entry.body?.includes(CAROL.slice(0, 8))), + "and the new join lands too", + ); + + await unmount(); + }); + + /** + * F1: a snapshot in flight across a community switch is folded into the + * session that requested it, or into nothing — never into the new + * community's ledger. + * + * `handleSnapshot` is a `useEffectEvent`, so before the session binding it + * read whatever community was CURRENTLY rendered. A frame from community A + * resolving after a switch to B would be reconciled against B's ledger and + * persisted under B's storage key, alerting for A's members under B's name. + */ + it("never folds a snapshot from the previous community into the new one", async () => { + const relay = installRelayStub(); + const { render, switchCommunity, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { createdAt: 70_000 })); + await settle(); + + const keyA = joinAlertStorageKey(COMMUNITY_A, VIEWER); + const keyB = joinAlertStorageKey(COMMUNITY_B, VIEWER); + const ledgerABefore = storage.get(keyA); + assert.ok(ledgerABefore, "precondition: community A seeded"); + assert.equal(storage.get(keyB), undefined, "precondition: B unseeded"); + + // Community A's refetch is held open across the switch. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-a-inflight", + createdAt: 71_000, + }), + ); + const releaseRefetch = relay.deferRefetch(); + relay.emitReconnect(); + await settleAfterRefreshDebounce(); + + await switchCommunity(COMMUNITY_B); + await settle(); + + // A's frame now resolves, with B active. + await releaseRefetch(); + await settleAfterNotifyWindow(); + + assert.equal( + storage.get(keyA), + ledgerABefore, + "the retired session must not write community A's ledger either", + ); + const ledgerB = storage.get(keyB); + if (ledgerB !== undefined) { + assert.ok( + !ledgerB.includes(BOB), + "community A's roster must never reach community B's ledger", + ); + } + assert.ok( + !notifications.some((entry) => entry.title?.includes("Community B")), + "community A's joiners must never be announced under community B", + ); + + await unmount(); + }); + + /** + * F2: the trailing window is a pure debounce, so a join cadence faster than + * the window re-arms it indefinitely. + * + * Measured before the clamp: 13 joins at ~700ms intervals produced ZERO + * notifications across 9.1 continuous seconds, with the ledger persisted the + * whole time — so a quit mid-drip loses a batch already recorded as alerted. + * The clamp bounds that. This arm drips faster than the window for longer + * than the ceiling and asserts delivery happens DURING the drip. + * + * The existing burst arm cannot catch this: it emits its snapshots in a tight + * loop inside one drain, so the window never re-arms against wall-clock time + * and the starvation is structurally unreachable there. + */ + it("delivers during a sustained drip instead of deferring without bound", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER], { createdAt: 80_000 })); + await settle(); + + const roster = [VIEWER]; + // 1s apart — inside the 1.5s window, so every join re-arms it — for 8s, + // which is past the 5s ceiling. + for (let i = 0; i < 8; i++) { + roster.push(`${i.toString(16).repeat(63)}e`); + relay.emitSnapshot( + snapshot([...roster], { + id: `snap-drip-${i}`, + createdAt: 80_001 + i, + }), + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 1_000)); + }); + } + + assert.ok( + notifications.length > 0, + `a sustained drip must not starve delivery; got ${notifications.length} alerts across 8s`, + ); + + await settleAfterNotifyWindow(); + await unmount(); + }); + /** + * The members panel must not turn each roster snapshot into a REQ frame. + * + * `useRelayMembersQuery` is the settings card's own query, and its queryFn + * `listRelayMembers` is a REQ (`fetchFirstEvent({ kinds: [13534], limit: 1 })`). + * `invalidateQueries` refetches every ACTIVE observer, so while the panel was + * open the snapshot handler emitted one REQ per accepted snapshot — measured + * 1:1 at 20 snapshots, both here and live against a real relay — against a + * per-principal budget of 50 REQ per 5s. Unlike the kind:8000 accelerator this + * path is not behind `MEMBER_REFRESH_DEBOUNCE_MS`, so nothing coalesced it. + * + * Both arms are asserted, and the second is what makes this test honest: + * DELETING the cache write also produces zero REQ, so a REQ-only assertion is + * satisfied by a fix that silently freezes the panel. The observed roster is + * the discriminator (measured: 21 keys with the write, 0 without it). + * + * The closed arm is the negative control — without it, a hook that stopped + * subscribing entirely would pass the open arm. + */ + it("keeps the members panel fresh across a burst without emitting a REQ per snapshot", async () => { + const SNAPSHOT_COUNT = 20; + const relay = installRelayStub(); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(["identity"], { pubkey: VIEWER }); + queryClient.setQueryData(myRelayMembershipLookupQueryKey, { + snapshotFound: true, + membershipRequired: true, + membership: { + pubkey: VIEWER, + role: "owner", + addedBy: null, + createdAt: null, + }, + }); + + // Mirrors CommunityMembersSettingsCard:251 — the real query hook, so this + // arm cannot pass by re-declaring the observer the regression runs through. + const observed = { roster: undefined }; + function MembersPanelObserver() { + observed.roster = useRelayMembersQuery(true).data; + return null; + } + + // The panel opens INSIDE the mounted tree, the way navigating to Settings + // does. Mounting a second tree instead would give the observer its own + // QueryClient and the invalidation could never reach it. + const openPanel = { current: null }; + function Harness() { + useCommunityJoinAlerts({ enabled: true }); + const [open, setOpen] = React.useState(false); + openPanel.current = setOpen; + return open ? React.createElement(MembersPanelObserver, null) : null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + const render = async () => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Harness, null), + ), + ), + ); + }); + }; + + /** Emit `SNAPSHOT_COUNT` growing rosters, returning REQ frames spent. */ + const burst = async (startAt) => { + const before = relay.counts().fetchFirstEventCalls; + const roster = [VIEWER]; + for (let i = 0; i < SNAPSHOT_COUNT; i++) { + roster.push(String(i).padStart(64, "e")); + const event = snapshot([...roster], { + id: `snap-req-${startAt}-${i}`, + createdAt: startAt + i, + }); + relay.setRefetchSnapshot(event); + relay.emitSnapshot(event); + await settle(2); + } + await settleAfterNotifyWindow(); + return relay.counts().fetchFirstEventCalls - before; + }; + + await render(); + await settle(); + + // Arm 1 — panel closed (negative control). + const closedArmReqs = await burst(1_000); + assert.equal( + closedArmReqs, + 0, + `panel closed must cost no REQ; spent ${closedArmReqs}`, + ); + + // Arm 2 — panel open: the regression arm. + await act(async () => { + openPanel.current(true); + }); + await settle(); + const openArmReqs = await burst(2_000); + + assert.equal( + openArmReqs, + 0, + `an open members panel must not cost a REQ per snapshot; spent ${openArmReqs} across ${SNAPSHOT_COUNT} snapshots`, + ); + + // The half a REQ count cannot see: deleting the write scores 0 REQ too. + assert.equal( + observed.roster?.length, + SNAPSHOT_COUNT + 1, + `the panel must observe the full roster (viewer + ${SNAPSHOT_COUNT}); got ${observed.roster?.length}`, + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/desktop/src/features/community-members/useCommunityJoinAlerts.ts b/desktop/src/features/community-members/useCommunityJoinAlerts.ts new file mode 100644 index 0000000000..72c8a2e912 --- /dev/null +++ b/desktop/src/features/community-members/useCommunityJoinAlerts.ts @@ -0,0 +1,538 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { + myRelayMembershipLookupQueryKey, + relayMembersQueryKey, +} from "@/features/community-members/hooks"; +import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + joinAlertBody, + joinAlertSummaryBody, + joinAlertTitle, + normalizeJoinPubkey, + readJoinAlertLedger, + reconcileJoinAlertLedger, + writeJoinAlertLedger, + type JoinAlertLedger, + JOIN_ALERT_MAX_INDIVIDUAL, +} from "@/features/community-members/lib/joinAlerts"; +import { sendDesktopNotification } from "@/features/notifications/lib/desktop"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { relayClient } from "@/shared/api/relayClient"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { + canManageCommunityMembers, + relayMembersFromEvent, +} from "@/shared/api/relayMembers"; +import { getUsersBatch } from "@/shared/api/tauriProfiles"; +import type { RelayEvent, RelayMember } from "@/shared/api/types"; + +const KIND_NIP43_MEMBERSHIP_LIST = 13534; +const KIND_NIP43_MEMBER_ADDED = 8000; + +/** + * Trailing window for coalescing kind:8000-triggered snapshot refetches. + * + * Long enough that a bulk add collapses to a single REQ, short enough that a + * lone join still feels immediate — the accelerator exists only to beat the + * live snapshot's own arrival, so sub-second is the whole budget. + */ +const MEMBER_REFRESH_DEBOUNCE_MS = 500; + +/** + * Everything one mounted effect run is allowed to act on. + * + * The subscription callbacks that deliver snapshots belong to the effect run + * that registered them, but `handleSnapshot` is a `useEffectEvent` and so reads + * whatever is *currently* rendered. Between the re-render that switches + * community and that effect's cleanup, those two disagree — and a snapshot from + * the old community would be folded into the new community's ledger under the + * new community's storage key. + * + * Binding the identity, the ledger, and the ordering state into one object + * created by the effect run turns those scattered ambient reads into a single + * value with an identity that can be compared. `handleSnapshot` still reads + * `sessionRef.current`, so it is the surrounding ordering that makes the bug + * unreachable: cleanup retires the session before the next run installs its + * own, each retired callback is stopped by its run's `disposed` flag, and every + * send boundary re-checks that the session it captured is still the live one. + */ +type JoinAlertSession = { + communityId: string; + viewerPubkey: string; + ledger: JoinAlertLedger; + /** + * `created_at` of the newest snapshot already folded in. + * + * A snapshot older than this is a stale view of the roster — an in-flight + * refetch that resolves after a newer live frame — and must not be treated as + * current. Without this, an older frame can re-alert a departed key or, worse, + * re-assert an authorization a newer frame just revoked. + */ + newestSnapshotAt: number; + /** + * Latched once a snapshot shows the viewer is no longer owner/admin. + * + * Fail-closed, and deliberately stronger than the `newestSnapshotAt` fence: + * the relay can publish two snapshots within the same second, so an + * equal-`created_at` stale frame passes a strictly-older fence. Dropping + * equal timestamps instead would discard legitimate same-second joins. The + * latch removes the timestamp from the safety argument entirely — once + * revocation is observed, this session is done disclosing, whatever order the + * remaining frames arrive in. + * + * Re-promotion is unaffected: nothing invalidates the membership lookup on + * promotion, so regaining the panel already requires a reload today. + */ + revoked: boolean; +}; + +/** + * Trailing quiet window for coalescing join alerts ACROSS snapshots. + * + * The per-snapshot cap bounds "one snapshot, many keys". It does nothing for + * "one burst, many snapshots": the relay republishes the whole 13534 as each + * concurrent add commits, so a 50-join storm arrives as a handful of growing + * rosters and each one independently emitted its own capped batch. Max measured + * 10 banners from 50 real joins at `fdeda44f0` for exactly this reason. + * + * Sized above the observed intermediate-snapshot cadence so a burst lands in + * one batch, and above MEMBER_REFRESH_DEBOUNCE_MS so an 8000-triggered refetch + * folds into the same window rather than flushing behind it. + */ +const JOIN_ALERT_NOTIFY_WINDOW_MS = 1_500; + +/** + * Ceiling on how long a batch may be deferred by the trailing window. + * + * `JOIN_ALERT_NOTIFY_WINDOW_MS` is a pure trailing debounce: every snapshot + * re-arms it, so a join cadence faster than the window defers delivery for as + * long as the joins keep coming. Measured before this clamp existed: 13 joins at + * ~700ms intervals produced zero notifications across 9.1 continuous seconds. + * + * That is the wrong shape for an alerting feature, and it is worse than mere + * lateness — the ledger is persisted per snapshot while delivery waits, so a + * quit or community switch mid-drip drops a batch the ledger already recorded as + * alerted, and it is never re-announced. Clamping bounds both the silence and + * that loss window. + * + * Sized against both ends rather than picked round: it must exceed the span a + * bulk add's intermediate snapshots occupy, or the clamp would split the burst + * this window exists to collapse, and it must sit BELOW the measured drip above, + * or it would leave the case that motivated it unchanged. A burst's snapshots + * arrive within a second or two of each other; the drip ran 9.1s. Five seconds + * clears the first by a wide margin and cuts the second roughly in half. + */ +const JOIN_ALERT_MAX_DEFERRAL_MS = 5_000; + +/** + * Notify community owners/admins the first time a key appears in their roster. + * + * Delivery rests on a live kind:13534 subscription because that snapshot is the + * only membership signal covering every join path with cross-pod propagation; + * see `lib/joinAlerts.ts` for the full rationale. Desktop's other 13534 read + * (`relayMembers.ts`) is a one-shot fetch, so without this subscription no + * snapshot ever arrives passively and nothing could fire. + * + * The kind:8000 delta is subscribed purely to shorten latency on the paths that + * emit one. It refreshes the authoritative snapshot rather than alerting from + * the delta's own payload, so one ledger governs both signals and the pair + * cannot double-alert. + * + * Viewer, community, and role are read from context rather than passed in: + * `AppShell` is at the file-size ratchet ceiling, so the mount has to stay a + * single call. + */ +export function useCommunityJoinAlerts({ enabled }: { enabled: boolean }) { + const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const membershipQuery = useMyRelayMembershipLookupQuery(); + + const communityId = activeCommunity?.id ?? null; + const communityName = activeCommunity?.name ?? null; + const normalizedViewer = normalizeJoinPubkey( + identityQuery.data?.pubkey ?? "", + ); + const active = + enabled && + canManageCommunityMembers(membershipQuery.data) && + communityId !== null && + normalizedViewer.length > 0; + + // Session for the current effect run. Callbacks read it through this ref so + // they stay stable — re-subscribing on every roster change would drop deltas + // in the gap between REQ and CLOSE — but every read is validated against the + // session's own bound community, never against ambient render state. + const sessionRef = React.useRef(null); + + // Community name is read fresh rather than captured, because a rename does not + // re-key the effect and a captured name would go stale. Guarded by id at use + // time so it can only ever label its own community. + const communityNameRef = React.useRef<{ id: string; name: string } | null>( + null, + ); + communityNameRef.current = + communityId === null + ? null + : { id: communityId, name: communityName ?? "" }; + + const resolveTitle = React.useCallback((session: JoinAlertSession) => { + const named = communityNameRef.current; + // Fall back to the generic title rather than a name belonging to a + // different community. + return joinAlertTitle( + named?.id === session.communityId ? named.name : null, + ); + }, []); + + // Pending cross-snapshot batch. A burst arrives as several growing rosters, + // so alerts accumulate here and flush once the roster stops moving. + // + // `pendingEventRef` holds the LATEST snapshot only, as the notification's + // click target. Every key in the batch is present in that roster (the ledger + // is monotonic within a burst), so the newest snapshot is the accurate + // referent for the whole batch. + const pendingRef = React.useRef([]); + const pendingEventRef = React.useRef(null); + const notifyTimerRef = React.useRef(null); + // When the batch currently pending first enqueued, for the deferral clamp. + const pendingSinceRef = React.useRef(null); + + // Cancellation token for flushes already past the refs. + // + // Clearing the refs cannot stop a flush that has already consumed them and + // is parked on an await, and every send in `flushPending` sits behind one: + // the profile lookup, and each notification itself. A demotion, removal, + // unmount, or community switch landing in that window would otherwise still + // deliver — Max and Wren both found this at 5d0d2b4c. + // + // Bumped ONLY by `clearPending`, never by an ordinary enqueue, so an + // authorized batch queued while an earlier flush's lookup is in flight + // neither cancels it nor is cancelled by it: both deliver. Cancellation is + // the only thing that invalidates a claim. + const flushGenerationRef = React.useRef(0); + + /** Drop anything queued but not yet delivered, in flight or not. */ + const clearPending = React.useCallback(() => { + pendingRef.current = []; + pendingEventRef.current = null; + pendingSinceRef.current = null; + flushGenerationRef.current += 1; + if (notifyTimerRef.current !== null) { + window.clearTimeout(notifyTimerRef.current); + notifyTimerRef.current = null; + } + }, []); + + const flushPending = React.useEffectEvent(async () => { + const session = sessionRef.current; + const alerts = pendingRef.current; + const event = pendingEventRef.current; + pendingRef.current = []; + pendingEventRef.current = null; + pendingSinceRef.current = null; + if (alerts.length === 0 || !event || !session) return; + // A session that observed revocation never delivers, even if a batch was + // queued before the latch closed. + if (session.revoked) return; + + // Claim this batch. Checked again at every side-effect boundary below — + // not merely after the awaits that exist today, so that adding an await + // later cannot silently reopen the disclosure. + const generation = flushGenerationRef.current; + const cancelled = () => + flushGenerationRef.current !== generation || + sessionRef.current !== session || + session.revoked; + + // Bind the title to the community these keys were queued under, not to + // whatever is active when the send resolves. + const title = resolveTitle(session); + + // Resolve display names so the alert reads "Alice joined" rather than a + // truncated key; a lookup failure degrades to the key, it does not skip. + // + // Above the cap the batch collapses into one summary, so skip the profile + // fetch entirely — it would be a 250-key request whose result is unused. + if (alerts.length > JOIN_ALERT_MAX_INDIVIDUAL) { + if (cancelled()) return; + await sendDesktopNotification({ + body: joinAlertSummaryBody(alerts.length), + target: { + channelId: null, + eventId: event.id, + kind: event.kind, + pubkey: undefined, + }, + title, + }); + return; + } + + let profiles: UserProfileLookup | undefined; + try { + profiles = (await getUsersBatch(alerts)).profiles; + } catch { + profiles = undefined; + } + + for (const pubkey of alerts) { + // Per-send, not once after the lookup: a demotion landing between two + // named sends must suppress the rest of the batch, not just the batch + // that had not started. + if (cancelled()) return; + await sendDesktopNotification({ + body: joinAlertBody( + resolveUserLabel({ preferResolvedSelfLabel: true, profiles, pubkey }), + ), + target: { + channelId: null, + eventId: event.id, + kind: event.kind, + pubkey, + }, + title, + }); + } + }); + + const handleSnapshot = React.useEffectEvent(async (event: RelayEvent) => { + const session = sessionRef.current; + if (!session) return; + // Already revoked: this session neither alerts nor learns anything further. + if (session.revoked) return; + + const roster = relayMembersFromEvent(event); + const rosterPubkeys = roster.map((member) => member.pubkey); + if (rosterPubkeys.length === 0) return; + + // Drop a stale view of the roster before it can be treated as current. + // + // An in-flight refetch (kind:8000 accelerator or reconnect) can resolve + // AFTER a newer live frame. Processing it would fold a superseded roster in + // as authoritative — re-alerting a departed key, and re-asserting an + // authorization the newer frame revoked. Strictly older only: two snapshots + // can share a second, and dropping equal timestamps would discard real + // joins. The revocation latch, not this fence, is what makes the privacy + // arm safe at equal timestamps. + const snapshotAt = event.created_at; + if (snapshotAt < session.newestSnapshotAt) return; + + // The roster can change shape without anything being new to us (a removal + // or a role change), so refresh the panel regardless of alert eligibility. + // + // Written directly rather than invalidated. `invalidateQueries` refetches + // every ACTIVE observer, and `listRelayMembers` is a REQ frame + // (`fetchFirstEvent({ kinds: [13534], limit: 1 })`), so with the members + // panel open this path emitted one REQ per accepted snapshot — measured + // 1:1 across 20 snapshots, live and in unit, against a documented budget + // of limit x window = 50 REQ per 5s (`default_human_ws()` = 10/s, + // `WS_BURST_WINDOW_SECS` = 5; REQ is billed as `WsEvents`). A join burst + // large enough to matter would rate-limit the owner out of their own app, + // and unlike the kind:8000 accelerator this path is not behind + // `MEMBER_REFRESH_DEBOUNCE_MS`. + // + // The refetch was never load-bearing: `roster` above is the output of the + // same `relayMembersFromEvent` parser `listRelayMembers` feeds the query + // with (`relayMembers.ts:125-127`), from a snapshot this session has + // already accepted as current — so the write is the identical shape and + // strictly fresher than a refetch, which would race the stream that + // triggered it. The stale fence above guarantees no superseded roster + // reaches here, and the query client is per-community + // (`CommunityQueryProvider key={communityKey}`, `App.tsx:556`), so this + // non-community-scoped key cannot be written across a switch. + queryClient.setQueryData(relayMembersQueryKey, roster); + + // Authorize against the snapshot in hand, not the cached role that mounted + // this effect. `useMyRelayMembershipLookupQuery` is only invalidated by this + // client's own membership mutations, and `staleTime` marks data stale + // without scheduling a refetch, so a viewer demoted by another admin keeps + // a cached owner/admin role for as long as the app stays open — and would + // otherwise keep learning every later joiner's identity from a role they no + // longer hold. The snapshot carries the viewer's own role + // (`["member", pubkey, role]`, relay-signed in `publish_nip43_membership_locked`), + // so the event that revokes authorization is the same event that would + // disclose the join. Checking it here closes that race in one read rather + // than racing an async invalidation. + // + // Fail closed: a snapshot that does not list the viewer at all means they + // were removed outright. + const viewerEntry = roster.find( + (member) => member.pubkey === session.viewerPubkey, + ); + if (viewerEntry?.role !== "owner" && viewerEntry?.role !== "admin") { + // Latch, so no later frame — including an older authorized snapshot still + // in flight — can re-open disclosure for this session. + session.revoked = true; + // Revocation must also drop anything queued but not yet delivered. + // Batching across snapshots would otherwise reopen the disclosure Wren + // found as a *delayed* one: joins accumulated while authorized would + // still fire from a timer after the snapshot that revoked the role. + clearPending(); + // Refresh the mount gate so the subscriptions themselves tear down. + void queryClient.invalidateQueries({ + queryKey: myRelayMembershipLookupQueryKey, + }); + return; + } + + // Fence advances only here: past the roster and authorization checks, on a + // frame this session actually accepts as its current view. Advancing it at + // the comparison instead would let a frame rejected for some *other* reason + // push the fence past a legitimate frame still in flight, dropping a real + // snapshot as though it were stale. + session.newestSnapshotAt = snapshotAt; + + const { alerts, changed, ledger } = reconcileJoinAlertLedger({ + ledger: session.ledger, + rosterPubkeys, + viewerPubkey: session.viewerPubkey, + }); + if (!changed) return; + + // Persisted before notifying, never after: a crash between the two must + // lose the notification rather than repeat it on every later snapshot. + // + // A write that cannot land (quota still exceeded after cache eviction) + // leaves the session's ledger alone deliberately. Advancing it would mark + // these keys seen in memory while nothing reached storage, so the alert + // would be lost until a reload; leaving it means the next snapshot retries + // the write and the alert survives to whichever attempt lands. The notify is + // skipped either way — a false return means nothing was persisted, so + // notifying here is exactly the "repeat on every later snapshot" this + // ordering exists to prevent. + if ( + !writeJoinAlertLedger(session.communityId, session.viewerPubkey, ledger) + ) { + return; + } + session.ledger = ledger; + if (alerts.length === 0) return; + + // Queue rather than notify. Persistence and the ledger advance stay + // synchronous per snapshot (above), so cross-snapshot dedupe still holds + // and a crash before the flush loses the alert rather than repeating it — + // the ordering invariant this feature already committed to. Only the + // delivery is deferred, onto a trailing quiet window, so one burst + // produces one alert instead of one per intermediate snapshot. + pendingRef.current.push(...alerts); + pendingEventRef.current = event; + if (notifyTimerRef.current !== null) { + window.clearTimeout(notifyTimerRef.current); + } + const now = Date.now(); + if (pendingSinceRef.current === null) pendingSinceRef.current = now; + // Clamp the trailing window so a sustained drip cannot defer delivery (and + // the ledger-already-written loss window) without bound. + const deadline = pendingSinceRef.current + JOIN_ALERT_MAX_DEFERRAL_MS; + const delay = Math.max( + 0, + Math.min(JOIN_ALERT_NOTIFY_WINDOW_MS, deadline - now), + ); + notifyTimerRef.current = window.setTimeout(() => { + notifyTimerRef.current = null; + void flushPending(); + }, delay); + }); + + React.useEffect(() => { + if (!active || communityId === null) return; + + // One session per effect run. Every callback below reaches this community's + // ledger and this viewer's role through it and cannot reach any other, so a + // switch mid-flight is a cancelled session rather than a mislabeled alert. + const session: JoinAlertSession = { + communityId, + ledger: readJoinAlertLedger(communityId, normalizedViewer), + newestSnapshotAt: 0, + revoked: false, + viewerPubkey: normalizedViewer, + }; + sessionRef.current = session; + + let disposed = false; + const disposers: Array<() => Promise> = []; + let refreshTimeout: number | null = null; + + const track = (unsubscribe: () => Promise) => { + if (disposed) { + void unsubscribe(); + return; + } + disposers.push(unsubscribe); + }; + + const fetchSnapshot = () => { + void relayClient + .fetchFirstEvent({ kinds: [KIND_NIP43_MEMBERSHIP_LIST], limit: 1 }) + .then((snapshot) => { + if (!disposed && snapshot) void handleSnapshot(snapshot); + }) + .catch(() => { + // Best effort: the live 13534 subscription still delivers. + }); + }; + + /** + * Coalesce refetches on a trailing window. + * + * Each refetch is a REQ frame, and REQ is billed against the same per- + * principal `WsEvents` budget as the user's own sends (default 10/s over a + * 5s window). A bulk add emits one kind:8000 per member, so an uncoalesced + * 1:1 refetch would spend the budget the owner needs for messages and + * channel opens — rate-limiting them out of their own app. One snapshot is + * authoritative for the whole burst, so the trailing edge loses nothing. + */ + const refreshSnapshot = () => { + if (disposed || refreshTimeout !== null) return; + refreshTimeout = window.setTimeout(() => { + refreshTimeout = null; + if (!disposed) fetchSnapshot(); + }, MEMBER_REFRESH_DEBOUNCE_MS); + }; + + void relayClient + .subscribeLive({ kinds: [KIND_NIP43_MEMBERSHIP_LIST], limit: 1 }, (e) => { + if (!disposed) void handleSnapshot(e); + }) + .then(track) + .catch((error) => { + console.error("Couldn’t subscribe to community membership", error); + }); + + // Accelerator only: refetch the authoritative snapshot instead of trusting + // the delta, so the ledger only ever sees one consistent roster view. + void relayClient + .subscribeLive({ kinds: [KIND_NIP43_MEMBER_ADDED], limit: 0 }, () => { + if (!disposed) refreshSnapshot(); + }) + .then(track) + .catch((error) => { + console.error("Couldn’t subscribe to community joins", error); + }); + + // A reconnect can span joins that landed while the socket was down, and + // `limit: 1` backfill is not guaranteed to redeliver them. + const unsubscribeReconnect = + relayClient.subscribeToReconnects(refreshSnapshot); + + return () => { + disposed = true; + if (refreshTimeout !== null) window.clearTimeout(refreshTimeout); + // Retire the session before dropping the batch, so any flush already past + // the refs sees `sessionRef.current !== session` and stops. Guarded in + // case a later run has already installed its own. + if (sessionRef.current === session) sessionRef.current = null; + // Drop the queued batch too, not just its timer: on a community switch + // this effect re-keys, and keys accumulated for the old community must + // not flush against the new one. + clearPending(); + unsubscribeReconnect(); + for (const dispose of disposers) void dispose(); + }; + }, [active, communityId, normalizedViewer, clearPending]); +} From 67b77344d61fa663411f99a9518296f31969078e Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 6 Aug 2026 17:13:55 -0700 Subject: [PATCH 14/16] fix(desktop): next/back navigation during key creation onboarding (#4978) **Category:** fix **User Impact:** Users can navigate back while an identity key is being created, while Next remains visible and unavailable until creation finishes. **Problem:** The key-creation hold hid both navigation actions, leaving users without an escape route or a clear indication of what would happen next. **Solution:** Keep the onboarding footer mounted throughout creation, leave Back enabled, and gate Next on the completed identity state.
File changes **desktop/src/features/onboarding/ui/BackupStep.tsx** Keeps the onboarding navigation footer visible during key creation, with Back available and Next disabled until the identity is ready. **desktop/tests/e2e/onboarding-backup.spec.ts** Covers the loading and completed navigation states so the intended behavior cannot quietly crawl back out of the pit.
## Reproduction steps 1. Start desktop onboarding and choose to create a new identity. 2. Submit the profile step and observe the key-creation screen. 3. Confirm Back is enabled while Next is visible but disabled. 4. Wait for key creation to finish and confirm Next becomes enabled. ## Screenshots | Before | After | | --- | --- | | Navigation actions are hidden during key creation. | Back remains enabled while Next stays visible and disabled. | | ![Before: key creation screen without navigation actions](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4978/key-creation-before-bird.png) | ![After: key creation screen with disabled Next and enabled Back](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4978/key-creation-after-bird.png) | Signed-off-by: Taylor Ho --- .../src/features/onboarding/ui/BackupStep.tsx | 42 +++++++++---------- desktop/tests/e2e/onboarding-backup.spec.ts | 4 ++ 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 99d9c6324d..2367b9faf9 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -410,29 +410,27 @@ export function BackupStep({ )} - {created ? ( - - + + - - - ) : null} + + ); } diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index df3528c8bd..4b9040bd2b 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -59,6 +59,9 @@ test("backup step appears on fresh-key path after profile submit", async ({ page.getByRole("heading", { name: "Creating your identity key" }), ).toBeVisible(); await expect(page.getByTestId("backup-intro-logo")).toBeVisible(); + await expect(page.getByTestId("onboarding-next")).toBeVisible(); + await expect(page.getByTestId("onboarding-next")).toBeDisabled(); + await expect(page.getByTestId("onboarding-back")).toBeEnabled(); await expect( page.getByRole("heading", { @@ -66,6 +69,7 @@ test("backup step appears on fresh-key path after profile submit", async ({ }), ).toBeVisible(); await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); + await expect(page.getByTestId("onboarding-next")).toBeEnabled(); }); // --------------------------------------------------------------------------- From f03de210cd0e384870aaa00cb1fa6985a75640ff Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 6 Aug 2026 17:14:12 -0700 Subject: [PATCH 15/16] fix(desktop): preserve authoritative agent avatars (#4984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Agent cards and catalog listings now show the avatar belonging to the identity they represent. **Problem:** Running agent cards could show a stale definition avatar instead of the concrete agent profile, while adding another publisher's catalog entry could let local edits repaint that publisher's listing. This made agent identity look inconsistent across My Agents and the Agent Catalog. **Solution:** Treat the concrete agent pubkey profile as authoritative for running-card avatars, with the linked definition as fallback. Keep relay publications authoritative for foreign catalog presentation while using local copies only for linkage and selection state. | before | after | |--|--| | Screenshot 2026-08-06 at 3 48
43 PM | Screenshot 2026-08-06 at 3 48
40 PM | | agent-set avatar not showing | agent-set avatar is showing | ## Changes
File changes **desktop/src/features/agents/lib/agentCardAvatar.ts** Adds the explicit avatar precedence rule for running agent cards and blocks avatar-dependent actions until the authoritative profile query settles. **desktop/src/features/agents/lib/agentCardAvatar.test.mjs** Covers profile precedence, definition fallback, blank avatar handling, and the profile-loading transition for linked-agent actions. **desktop/src/features/agents/lib/personaCatalogRelay.ts** Keeps publisher-provided catalog identity and behavior fields authoritative after a local copy is added. **desktop/src/features/agents/lib/personaCatalogRelay.test.mjs** Verifies local copies contribute linkage and selection without overriding publisher presentation. **desktop/src/features/agents/ui/UnifiedAgentsSection.tsx** Uses the concrete agent profile avatar before the linked definition avatar on running-agent cards.
## Reproduction Steps ### Running agent card uses the agent profile avatar Use two visibly different, publicly reachable image URLs: **A** for the saved definition and **B** for the running agent profile. 1. In **Settings → Experiments**, enable **Agent-managed profiles**. This prevents Desktop from restoring the definition avatar over an agent's own relay-profile changes. 2. In **Agents**, create an agent with image **A** as its avatar and start it. 3. In a channel containing that agent, ask it to update its own Buzz profile avatar to image **B**. The exact CLI operation under the agent identity is `buzz users set-profile --avatar `. 4. After the agent confirms the update, reopen **Agents → My Agents** (or reload the page so its kind:0 profile is fetched again). 5. Verify the running agent card shows image **B**, not definition image **A**. Open **⋯ → Share** and verify the share flow also uses image **B**. Before this fix, the My Agents card and share flow preferred image **A** whenever the linked definition had an avatar. ### Catalog listing remains publisher-authoritative This scenario requires a second Buzz identity so the entry is foreign to the account under test. 1. As the publisher identity, create an agent definition with a distinctive name, avatar, and instructions, then use **Share → Share to catalog**. 2. As the test identity, open **Agents → Discover agents**, find that publication, and add it. 3. In **My Agents**, open the added copy's **⋯ → Edit**, change its name, avatar, and instructions, and save. 4. Return to **Discover agents** and find the same publisher entry. 5. Verify it remains selected/added but still shows the publisher's original name, avatar, and instructions—not the test identity's local edits. ## Validation - `pnpm test` — 4,376 passed - `pnpm typecheck` — passed - `pnpm check` — passed with existing non-error notices --------- Signed-off-by: Taylor Ho --- .../agents/lib/agentCardAvatar.test.mjs | 37 +++++++++++++++++++ .../features/agents/lib/agentCardAvatar.ts | 29 +++++++++++++++ .../agents/lib/personaCatalogRelay.test.mjs | 17 +++++++-- .../agents/lib/personaCatalogRelay.ts | 12 ++++-- .../agents/ui/UnifiedAgentsSection.tsx | 18 +++------ 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 desktop/src/features/agents/lib/agentCardAvatar.test.mjs create mode 100644 desktop/src/features/agents/lib/agentCardAvatar.ts diff --git a/desktop/src/features/agents/lib/agentCardAvatar.test.mjs b/desktop/src/features/agents/lib/agentCardAvatar.test.mjs new file mode 100644 index 0000000000..5acd9ae109 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardAvatar.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isAgentCardAvatarLoading, + resolveAgentCardAvatarUrl, +} from "./agentCardAvatar.ts"; + +test("running agent card prefers the pubkey profile avatar", () => { + assert.equal( + resolveAgentCardAvatarUrl( + "https://relay.example/instance.png", + "https://relay.example/definition.png", + ), + "https://relay.example/instance.png", + ); +}); + +test("running agent card falls back to the definition avatar", () => { + assert.equal( + resolveAgentCardAvatarUrl(null, " https://relay.example/definition.png "), + "https://relay.example/definition.png", + ); +}); + +test("running agent card ignores blank avatar values", () => { + assert.equal(resolveAgentCardAvatarUrl(" ", ""), null); +}); + +test("linked agent actions wait for the authoritative profile avatar", () => { + assert.equal(isAgentCardAvatarLoading(true, true), true); + assert.equal(isAgentCardAvatarLoading(true, false), false); +}); + +test("unlinked persona actions do not wait for a profile", () => { + assert.equal(isAgentCardAvatarLoading(false, true), false); +}); diff --git a/desktop/src/features/agents/lib/agentCardAvatar.ts b/desktop/src/features/agents/lib/agentCardAvatar.ts new file mode 100644 index 0000000000..057c413daa --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardAvatar.ts @@ -0,0 +1,29 @@ +/** + * Resolve the avatar for a running agent card. + * + * The card opens the concrete agent pubkey's profile, so that profile's kind:0 + * picture is authoritative. The linked definition remains a fallback while the + * profile is missing or has no picture. + */ +export function resolveAgentCardAvatarUrl( + profileAvatarUrl: string | null | undefined, + personaAvatarUrl: string | null | undefined, +): string | null { + for (const candidate of [profileAvatarUrl, personaAvatarUrl]) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return null; +} + +/** + * A linked agent's profile is authoritative even when the definition already + * supplies a fallback. Avatar-dependent actions must wait for that profile + * query so they cannot snapshot the fallback before the profile resolves. + */ +export function isAgentCardAvatarLoading( + hasLinkedAgent: boolean, + isProfilePending: boolean, +): boolean { + return hasLinkedAgent && isProfilePending; +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f5274..ef516f4b01 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -319,12 +319,20 @@ function localPersona(overrides = {}) { // The duplicate-add bug: a copy of Alice's entry carries a fresh local UUID, so // matching by id finds nothing and the catalog offers "Add" again. Only the // stored catalogSource coordinate links the copy back to the publication. -test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => { +test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_selection", () => { + const publisherAvatar = "https://relay.example/publisher.png"; const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "alice-reviewer" }), + personaEvent({ + createdAt: 1, + id: "alice-reviewer", + avatarUrl: publisherAvatar, + }), ]); const copy = localPersona({ id: "a-fresh-uuid", + displayName: "Locally Renamed Reviewer", + avatarUrl: "https://relay.example/local-copy.png", + systemPrompt: "Locally edited instructions.", catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, }); @@ -334,13 +342,16 @@ test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => { assert.equal( personas[0].id, "a-fresh-uuid", - "the projection must resolve to the existing local copy, not a synthetic id", + "the projection must retain the existing local copy's linkage id", ); assert.equal( personas[0].isActive, true, "an added foreign entry must read as already selected", ); + assert.equal(personas[0].displayName, "Relay Reviewer"); + assert.equal(personas[0].avatarUrl, publisherAvatar); + assert.equal(personas[0].systemPrompt, "Review changes."); }); test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 02c3f8e202..a588843b1e 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -289,8 +289,14 @@ function publicationToPersona( isOwn: boolean, ): CatalogPersona { const timestamp = new Date(publication.createdAt * 1_000).toISOString(); - const basePersona: AgentPersona = localPersona ?? { - id: `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, + // The publication remains authoritative for catalog presentation. An added + // local copy contributes only the linkage id and selected state; merging the + // whole copy would leak local edits (notably its avatar) into the publisher's + // catalog entry. + const basePersona: AgentPersona = { + id: + localPersona?.id ?? + `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, displayName: publication.agent.displayName, avatarUrl: publication.agent.avatarUrl, systemPrompt: publication.agent.systemPrompt, @@ -299,7 +305,7 @@ function publicationToPersona( provider: publication.agent.provider, namePool: publication.agent.namePool, isBuiltIn: false, - isActive: false, + isActive: localPersona?.isActive ?? false, shared: true, sourceTeam: null, envVars: {}, diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 212d9bc96e..73562bda35 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { + isAgentCardAvatarLoading, + resolveAgentCardAvatarUrl, +} from "@/features/agents/lib/agentCardAvatar"; import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; @@ -290,7 +294,7 @@ function AgentPersonaCard({ const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); const avatarUrl = agent - ? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl) + ? resolveAgentCardAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl) : persona.avatarUrl; const friendlyError = agent ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy @@ -301,7 +305,7 @@ function AgentPersonaCard({ -): string | null { - for (const candidate of candidates) { - const trimmed = candidate?.trim(); - if (trimmed) return trimmed; - } - return null; -} - function NewAgentCard({ isPending, onCreate, From 769ac70b741e3ad6809bff14eba29d3dd2cbd318 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Thu, 6 Aug 2026 19:46:42 -0500 Subject: [PATCH 16/16] fix(media): require authenticated reads (#4610) This change requires a valid signed Blossom authorization request and current relay membership for every media GET and HEAD request. It removes the unauthenticated compatibility path and updates desktop reads to send the required authorization. This blocks anonymous retrieval and access after relay-membership revocation. It does not yet bind a blob to its originating channel, so someone removed from a private channel can still read a known blob while remaining a relay member. That channel-ACL follow-up remains required before closing the full finding. ## Testing - `git diff --check origin/main...codex/security-media-read-auth` - Rebased onto `origin/main` at `5c98932` - Full CI pending Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom Signed-off-by: Alex Rosenzweig Signed-off-by: Eli Foster Co-authored-by: Eli Foster Co-authored-by: Claude Opus 5 --- .env.example | 9 +- .github/workflows/ci.yml | 13 +++ TESTING.md | 1 - crates/buzz-relay/src/api/media.rs | 53 +++------ crates/buzz-relay/src/config.rs | 102 +++++++++++++++--- .../tests/conformance_multitenant.rs | 22 ++-- crates/buzz-test-client/tests/e2e_media.rs | 90 +++++++++++++++- .../tests/e2e_media_extended.rs | 39 +++++-- .../buzz-test-client/tests/e2e_media_video.rs | 35 +++++- deploy/charts/buzz/templates/NOTES.txt | 5 - deploy/charts/buzz/templates/deployment.yaml | 1 - deploy/charts/buzz/tests/render_test.yaml | 29 ----- deploy/charts/buzz/values.schema.json | 1 - deploy/charts/buzz/values.yaml | 6 -- desktop/src-tauri/src/commands/media.rs | 8 +- .../src-tauri/src/commands/personas/card.rs | 6 +- docs/admin/README.md | 6 +- docs/multi-tenant-conformance.md | 2 +- 18 files changed, 295 insertions(+), 133 deletions(-) diff --git a/.env.example b/.env.example index b9bfcada0e..0f7bbba6f1 100644 --- a/.env.example +++ b/.env.example @@ -102,11 +102,10 @@ BUZZ_S3_ADDRESSING_STYLE=path # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2 # BUZZ_MEDIA_UPLOADS_PER_MINUTE=30 -# Require Blossom t=get auth and relay membership for GET/HEAD /media/*. -# Keep off until desktop/mobile/CLI clients that attach media read auth are deployed. -# BUZZ_REQUIRE_MEDIA_GET_AUTH=false -# Legacy alias accepted by the relay while rollout docs catch up: -# BUZZ_REQUIRE_MEDIA_READ_AUTH=false +# GET/HEAD /media/* always require Blossom t=get auth and relay membership. +# BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer +# read; setting either (including to false) changes nothing and the relay warns +# about it at startup. # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65157705a..299fc9efe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -768,6 +768,19 @@ jobs: env: RELAY_URL: ws://localhost:3000 GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr + - name: Media read-auth e2e + # Reads require kind:24242 `t=get` auth, so these binaries are the only + # coverage that a real relay rejects bare reads and honours host- and + # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so + # the lane never ran; select it here, where MinIO and the seeded + # 'localhost:3000' community already exist. + # --no-fail-fast: without it cargo stops after the first failing binary, + # so one broken case hides every later binary's result. + run: | + cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 - name: Upload relay logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/TESTING.md b/TESTING.md index 764b86d408..7c107da575 100644 --- a/TESTING.md +++ b/TESTING.md @@ -277,7 +277,6 @@ out of the box with `just setup` or `just relay`. Common overrides: | `REDIS_URL` | `redis://localhost:6379` | | | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | -| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. | | `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. | | `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc26..a2f3640bde 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -493,10 +493,6 @@ async fn authenticate_media_read( ) -> Result { let tenant = bind_media_read_tenant(state, headers).await?; - if !state.config.require_media_get_auth { - return Ok(MediaReadAuth { tenant }); - } - let auth_event = extract_blossom_auth(headers)?; let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; @@ -514,12 +510,8 @@ async fn authenticate_media_read( Ok(MediaReadAuth { tenant }) } -fn blob_cache_control(require_auth: bool) -> &'static str { - if require_auth { - "private, max-age=31536000, immutable" - } else { - "public, max-age=31536000, immutable" - } +fn blob_cache_control() -> &'static str { + "private, max-age=31536000, immutable" } /// Whether a path-segment extension is a safe token. @@ -623,7 +615,7 @@ pub(crate) async fn serve_blob_for_tenant( req_headers: &HeaderMap, ) -> Result { validate_media_path(sha256_ext)?; - let cache_control = blob_cache_control(state.config.require_media_get_auth); + let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -801,10 +793,9 @@ pub async fn head_blob( Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; - let require_media_get_auth = state.config.require_media_get_auth; let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; let tenant = media_auth.tenant; - let cache_control = blob_cache_control(require_media_get_auth); + let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -946,13 +937,8 @@ mod tests { } async fn test_state() -> Arc { - test_state_with_media_get_auth(false).await - } - - async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; - config.require_media_get_auth = require_media_get_auth; config.redis_url = "redis://127.0.0.1:1".to_string(); config.media_uploads_per_minute = 1; config.media_max_concurrent_uploads = 2; @@ -994,8 +980,8 @@ mod tests { Arc::new(state) } - async fn media_get_auth_router(require_media_get_auth: bool) -> axum::Router { - let state = test_state_with_media_get_auth(require_media_get_auth).await; + async fn media_get_auth_router() -> axum::Router { + let state = test_state().await; axum::Router::new() .route( "/media/{sha256_ext}", @@ -1041,20 +1027,9 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_off_allows_unauthenticated_read_until_sidecar_gate() { - let response = media_get_auth_router(false) - .await - .oneshot(media_request("GET", None)) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn media_get_auth_flag_on_rejects_unauthenticated_get_and_head_before_sidecar_gate() { + async fn media_reads_reject_unauthenticated_get_and_head_before_sidecar_gate() { for method in ["GET", "HEAD"] { - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request(method, None)) .await @@ -1065,10 +1040,10 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_valid_server_scoped_token_reaches_sidecar_gate() { + async fn media_read_with_valid_server_scoped_token_reaches_sidecar_gate() { let keys = Keys::generate(); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request("GET", Some(auth))) .await @@ -1078,7 +1053,7 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() { + async fn media_read_rejects_upload_verb_wrong_server_and_wrong_x() { let keys = Keys::generate(); let now = Timestamp::now().as_secs(); let expiration = (now + 300).to_string(); @@ -1102,7 +1077,7 @@ mod tests { for tags in cases { let auth = media_get_auth_header(&keys, tags); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request("GET", Some(auth))) .await @@ -1119,7 +1094,7 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_accepts_range_header_only_after_auth() { + async fn media_read_accepts_range_header_only_after_auth() { let keys = Keys::generate(); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); let mut request = media_request("GET", Some(auth)); @@ -1127,7 +1102,7 @@ mod tests { .headers_mut() .insert(header::RANGE, "bytes=0-0".parse().expect("range header")); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(request) .await diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index dd50973d03..037c6b1dd3 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -227,10 +227,6 @@ pub struct Config { /// Maximum media upload starts accepted from one pubkey per minute. pub media_uploads_per_minute: u32, - /// Require Blossom kind:24242 `t=get` auth plus relay membership before - /// serving media GET/HEAD. Default off for staged client rollout. - pub require_media_get_auth: bool, - /// Whether tamper-evident event/media audit logging is enabled. Defaults to true. /// This does not control the separate `moderation_actions` audit trail. /// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it. @@ -435,6 +431,31 @@ fn ensure_git_path( Ok(git_repo_path) } +/// Env vars that once gated authenticated media reads. +/// +/// `BUZZ_REQUIRE_MEDIA_GET_AUTH` was the real flag; `BUZZ_REQUIRE_MEDIA_READ_AUTH` +/// was documented in `.env.example` as an accepted alias but was never read by +/// the relay. Media reads are now unconditionally authenticated, so both are +/// inert and an operator still setting either — especially to `false` — holds a +/// belief about their deployment that is no longer true. +const INERT_MEDIA_READ_AUTH_VARS: [&str; 2] = [ + "BUZZ_REQUIRE_MEDIA_GET_AUTH", + "BUZZ_REQUIRE_MEDIA_READ_AUTH", +]; + +/// Which of `names` are present, so startup can warn that they do nothing. +/// +/// `lookup` is injected rather than calling `std::env::var` directly: process +/// env is global mutable state, so a test that set real vars would race every +/// other test in the binary. +fn inert_env_vars<'a>(names: &[&'a str], lookup: impl Fn(&str) -> Option) -> Vec<&'a str> { + names + .iter() + .copied() + .filter(|name| lookup(name).is_some()) + .collect() +} + impl Config { /// Loads configuration from environment variables, falling back to development defaults. pub fn from_env() -> Result { @@ -776,14 +797,13 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(30); - let require_media_get_auth = std::env::var("BUZZ_REQUIRE_MEDIA_GET_AUTH") - .map(|v| { - v == "true" - || v == "1" - || v.eq_ignore_ascii_case("yes") - || v.eq_ignore_ascii_case("on") - }) - .unwrap_or(false); + for name in inert_env_vars(&INERT_MEDIA_READ_AUTH_VARS, |n| std::env::var(n).ok()) { + warn!( + "{name} is set but is no longer read — GET/HEAD /media/* always require \ + Blossom t=get auth plus relay membership. Remove it; a value of `false` \ + does not re-open unauthenticated media reads." + ); + } let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE") .ok() @@ -1003,7 +1023,6 @@ impl Config { media_max_concurrent_uploads, media_max_concurrent_uploads_per_pubkey, media_uploads_per_minute, - require_media_get_auth, audit_enabled, ephemeral_ttl_override, git_repo_path, @@ -1035,6 +1054,59 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Look up against a fixed set, standing in for process env. + fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + use<'a> { + move |name| { + set.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_string()) + } + } + + /// The case that matters: an operator who pinned the old flag to `false` + /// must be told it is inert, not left believing media reads are still open. + #[test] + fn inert_media_read_auth_vars_are_reported_even_when_false() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[("BUZZ_REQUIRE_MEDIA_GET_AUTH", "false")]), + ); + + assert_eq!(found, vec!["BUZZ_REQUIRE_MEDIA_GET_AUTH"]); + } + + /// `BUZZ_REQUIRE_MEDIA_READ_AUTH` was advertised in `.env.example` as an + /// accepted alias but the relay never read it, so operators may hold it + /// today. It warns too. + #[test] + fn inert_media_read_auth_vars_include_the_documented_alias() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[ + ("BUZZ_REQUIRE_MEDIA_GET_AUTH", "true"), + ("BUZZ_REQUIRE_MEDIA_READ_AUTH", "false"), + ]), + ); + + assert_eq!( + found, + vec![ + "BUZZ_REQUIRE_MEDIA_GET_AUTH", + "BUZZ_REQUIRE_MEDIA_READ_AUTH" + ] + ); + } + + #[test] + fn inert_media_read_auth_vars_stay_quiet_when_unset() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true")]), + ); + + assert!(found.is_empty(), "unrelated vars must not warn: {found:?}"); + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); @@ -1072,10 +1144,6 @@ mod tests { !config.serve_git_web_gui, "serve_git_web_gui should default to false" ); - assert!( - !config.require_media_get_auth, - "require_media_get_auth should default to false for staged client rollout" - ); assert_eq!( config.media.s3_addressing_style, buzz_media::config::S3AddressingStyle::Path, diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 15002142e4..4c8c8904ac 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -2612,17 +2612,27 @@ mod pubsub_presence_typing { mod media_blossom { use super::*; - /// Obligation: public blob `GET/HEAD /media/{sha256.ext}` stays - /// unauthenticated (N=1 compat, shared CAS bytes). The community boundary is - /// the metadata/descriptor/upload-auth/quota/audit layer: B's private upload - /// metadata/errors must not be observable from A, even when the blob bytes - /// are deduplicated and shared. + /// Obligation: blob `GET/HEAD /media/{sha256.ext}` requires Blossom read auth + /// scoped to the serving host or the blob hash, and the request is bound to the + /// tenant resolved from the request headers. A bare read is rejected before any + /// storage lookup, so the endpoint does not leak blob existence. + /// + /// CAS bytes are still deduplicated across communities, so the boundary is not + /// the bytes: it is the metadata/descriptor/upload-auth/quota/audit layer plus + /// the per-tenant read binding. B's private upload metadata and errors must not + /// be observable from A even when the underlying blob is shared. + /// + /// Known limitation, deferred: relay membership plus knowledge of a hash is + /// sufficient to read a blob. Read auth binds host and tenant, not the channel + /// ACL of the message the blob was attached to. #[tokio::test] #[ignore] async fn media_metadata_boundary_holds_while_blob_bytes_shared() { pending_lane( "buzz-media", - "shared SHA bytes OK; A cannot read B's upload metadata/quota/audit; errors generic", + "reads require host/hash-scoped Blossom auth and bind to the header tenant; \ + bare reads 401 before storage; shared SHA bytes OK; A cannot read B's upload \ + metadata/quota/audit; errors generic", ); } } diff --git a/crates/buzz-test-client/tests/e2e_media.rs b/crates/buzz-test-client/tests/e2e_media.rs index 14001f641c..690fd9c8a5 100644 --- a/crates/buzz-test-client/tests/e2e_media.rs +++ b/crates/buzz-test-client/tests/e2e_media.rs @@ -48,6 +48,26 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .expect("sign blossom auth") } +/// Sign a kind:24242 Blossom *read* auth event for the given sha256. +/// +/// Reads are authenticated unconditionally, so every successful GET/HEAD in this +/// file has to present one of these. The `x` tag is hash-scoped and covers the +/// derived paths too -- the relay matches on the sha256 before the extension, so +/// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["x", sha256]).expect("x tag"), + Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom get auth") +} + /// Build `Authorization: Nostr ` header value. fn blossom_auth_header(event: &nostr::Event) -> String { format!( @@ -144,10 +164,14 @@ async fn test_upload_and_get() { descriptor["dim"], descriptor["blurhash"] ); + // Reads are authenticated, so mint one hash-scoped token for all three below. + let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)); + // GET /media/{sha256}.jpg — bytes must match let get_url = format!("{}/media/{sha256}.jpg", relay_http_url()); let get_resp = client .get(&get_url) + .header("Authorization", &read_auth) .send() .await .expect("GET /media/{sha256}.jpg failed"); @@ -162,6 +186,7 @@ async fn test_upload_and_get() { // HEAD /media/{sha256}.jpg — must return 200 with content-type let head_resp = client .head(&get_url) + .header("Authorization", &read_auth) .send() .await .expect("HEAD /media/{sha256}.jpg failed"); @@ -175,6 +200,7 @@ async fn test_upload_and_get() { let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url()); let thumb_resp = client .get(&thumb_url) + .header("Authorization", &read_auth) .send() .await .expect("GET thumbnail failed"); @@ -293,19 +319,69 @@ async fn test_upload_hash_mismatch_returns_400() { assert_eq!(resp.status(), 401, "hash mismatch must be 401"); } -/// GET a sha256 that was never uploaded must return 404. +/// GET an authenticated sha256 that was never uploaded must return 404. +/// +/// The token has to be valid for the 404 to be reachable at all: authentication +/// runs before the storage lookup, so a bare request is rejected with 401 and +/// never distinguishes "missing" from "unauthorized" (see +/// `test_unauthenticated_reads_are_rejected`). #[tokio::test] #[ignore] async fn test_get_nonexistent_returns_404() { let client = http_client(); + let keys = Keys::generate(); let missing_sha256 = "0".repeat(64); let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); - let resp = client.get(&url).send().await.expect("GET failed"); + let resp = client + .get(&url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &missing_sha256)), + ) + .send() + .await + .expect("GET failed"); println!("missing blob → {}", resp.status()); assert_eq!(resp.status(), 404, "missing blob must be 404"); } +/// Bare reads are rejected with 401 before any storage lookup. +/// +/// This is the boundary PR #4610 made unconditional: there is no longer a config +/// flag that lets an unauthenticated GET through, so the acceptance lane has to +/// assert the rejection directly. Uses a never-uploaded hash deliberately -- a 401 +/// here rather than a 404 proves auth runs ahead of the storage lookup and that the +/// endpoint does not leak blob existence to an unauthenticated caller. +#[tokio::test] +#[ignore] +async fn test_unauthenticated_reads_are_rejected() { + let client = http_client(); + let missing_sha256 = "0".repeat(64); + let blob_url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); + let thumb_url = format!("{}/media/{missing_sha256}.thumb.jpg", relay_http_url()); + + let get_resp = client.get(&blob_url).send().await.expect("bare GET failed"); + println!("bare GET → {}", get_resp.status()); + assert_eq!(get_resp.status(), 401, "bare GET must be 401"); + + let head_resp = client + .head(&blob_url) + .send() + .await + .expect("bare HEAD failed"); + println!("bare HEAD → {}", head_resp.status()); + assert_eq!(head_resp.status(), 401, "bare HEAD must be 401"); + + let thumb_resp = client + .get(&thumb_url) + .send() + .await + .expect("bare thumbnail GET failed"); + println!("bare thumbnail GET → {}", thumb_resp.status()); + assert_eq!(thumb_resp.status(), 401, "bare thumbnail GET must be 401"); +} + /// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var). /// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match. #[tokio::test] @@ -363,7 +439,15 @@ async fn test_upload_real_image() { // GET bytes back and verify let get_url = descriptor["url"].as_str().unwrap(); - let get_resp = client.get(get_url).send().await.expect("GET failed"); + let get_resp = client + .get(get_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) + .send() + .await + .expect("GET failed"); assert_eq!(get_resp.status(), 200); let returned = get_resp.bytes().await.unwrap(); assert_eq!( diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index 955bd9d6c4..8a9283c040 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -39,6 +39,21 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .unwrap() } +/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated +/// unconditionally, so round-trip GETs must present one of these. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let tags = vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -98,15 +113,15 @@ fn tiny_jpeg() -> Vec { } fn tiny_png() -> Vec { - // Valid 2x2 red PNG generated by ffmpeg + // Valid 2x2 red PNG generated by ffmpeg, with ffmpeg's pHYs chunk stripped: + // `validate_png_metadata_free` rejects pHYs as an identity channel, so the + // original fixture uploaded as 422 MetadataForbidden. IHDR/IDAT/IEND only. vec![ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd, - 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x4f, 0x25, 0xc4, 0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, - 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, - 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, - 0xae, 0x42, 0x60, 0x82, + 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, + 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, + 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ] } @@ -168,9 +183,14 @@ async fn test_upload_png_roundtrip() { assert!(desc["url"].as_str().unwrap().ends_with(".png")); println!("✅ PNG upload: {}", desc["url"]); - // GET back + // GET back — reads are authenticated, so scope a token to the uploaded hash. + let sha256 = desc["sha256"].as_str().expect("descriptor sha256"); let get = client .get(desc["url"].as_str().unwrap()) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) .send() .await .unwrap(); @@ -192,8 +212,13 @@ async fn test_upload_gif_roundtrip() { assert!(desc["url"].as_str().unwrap().ends_with(".gif")); println!("✅ GIF upload: {}", desc["url"]); + let sha256 = desc["sha256"].as_str().expect("descriptor sha256"); let get = client .get(desc["url"].as_str().unwrap()) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) .send() .await .unwrap(); diff --git a/crates/buzz-test-client/tests/e2e_media_video.rs b/crates/buzz-test-client/tests/e2e_media_video.rs index 64a5878f13..2ec0b1e698 100644 --- a/crates/buzz-test-client/tests/e2e_media_video.rs +++ b/crates/buzz-test-client/tests/e2e_media_video.rs @@ -40,6 +40,23 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .expect("sign blossom auth") } +/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated +/// unconditionally, so blob and range GETs must present one of these -- without it +/// the 206 and 416 range behaviour below would never be reached. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["x", sha256]).expect("x tag"), + Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom get auth") +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -272,7 +289,15 @@ async fn test_video_upload_and_get() { // GET the blob back let get_url = desc["url"].as_str().unwrap(); - let get_resp = client.get(get_url).send().await.expect("GET blob"); + let get_resp = client + .get(get_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) + .send() + .await + .expect("GET blob"); assert_eq!(get_resp.status(), StatusCode::OK); let body = get_resp.bytes().await.expect("body bytes"); assert_eq!(body.len(), mp4.len()); @@ -345,6 +370,10 @@ async fn test_video_range_request_206() { // Range request: first 100 bytes let range_resp = client .get(blob_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) .header("Range", "bytes=0-99") .send() .await @@ -389,6 +418,10 @@ async fn test_video_range_request_416() { // Request a range beyond the file size let range_resp = client .get(blob_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) .header( "Range", format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000), diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt index b409f4d942..a0dd96a1a4 100644 --- a/deploy/charts/buzz/templates/NOTES.txt +++ b/deploy/charts/buzz/templates/NOTES.txt @@ -62,11 +62,6 @@ {{- if not .Values.relay.requireRelayMembership }} ⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish. {{- end }} -{{- if not .Values.relay.requireMediaGetAuth }} - ⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated. - 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. You must run `buzz-admin migrate` against the database before every diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 5c876f7d24..0ad41ac461 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -131,7 +131,6 @@ spec: - { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } - - { name: BUZZ_REQUIRE_MEDIA_GET_AUTH, value: {{ .Values.relay.requireMediaGetAuth | quote }} } - { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} } - { name: BUZZ_PUBKEY_ALLOWLIST, value: {{ .Values.relay.pubkeyAllowlist | quote }} } {{- if .Values.relay.corsOrigins }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index cf08210781..196a4a5303 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -48,17 +48,6 @@ 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: renders virtual-hosted S3 addressing for providers that require it set: relayUrl: wss://buzz.example.com @@ -85,24 +74,6 @@ tests: value: "virtual" 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: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index e1e362a531..d3670595b5 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -62,7 +62,6 @@ "drainJitterMs": { "type": "integer", "minimum": 0 }, "requireAuthToken": { "type": "boolean" }, "requireRelayMembership": { "type": "boolean" }, - "requireMediaGetAuth": { "type": "boolean" }, "allowNipOaAuth": { "type": "boolean" }, "huddleAudioAvailable": { "type": ["boolean", "null"], diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 42b09f1b3e..8131aef432 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -117,12 +117,6 @@ relay: drainJitterMs: 0 requireAuthToken: true requireRelayMembership: true - # 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/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 86a91a9842..070381f55e 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -350,11 +350,9 @@ pub(crate) fn sign_blossom_get_auth_header( /// Mint a `t=get` Authorization header value for a relay media fetch, or /// `None` when signing is unavailable (identity in recovery mode). /// -/// Fail-open by design: while the relay's `BUZZ_REQUIRE_MEDIA_GET_AUTH` flag -/// is off, an unauthenticated request still succeeds, so degrading to no -/// header (instead of erroring) keeps media rendering during key recovery. -/// Once the flag is on, these requests will 403 — the correct outcome for an -/// identity that can't prove membership. +/// When signing is unavailable, callers send no header and the relay rejects +/// the read. This keeps recovery mode from accidentally treating a media URL +/// as a bearer capability. /// /// Safety contract: callers must only attach the returned header to URLs /// constructed from (or validated against) the app's own relay base URL — diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 29a5c35e6a..14c7c196b2 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -668,9 +668,9 @@ pub async fn mint_agent_card( .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, Some(url) if url.starts_with("http://") || url.starts_with("https://") => { // Relay-hosted avatars (kind:0 pictures under the relay's /media/) - // may require Blossom get-auth (`require_media_get_auth`). Mint the - // header ONLY for same-origin URLs so the token never leaves the - // relay (same contract as `media_download.rs`). + // require Blossom get-auth. Mint the header ONLY for same-origin URLs + // so the token never leaves the relay (same contract as + // `media_download.rs`). let relay_base = crate::relay::relay_api_base_url_with_override(&state); let auth = is_same_origin(url, &relay_base) .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) diff --git a/docs/admin/README.md b/docs/admin/README.md index e51566fb29..f49cbdd71a 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -54,9 +54,9 @@ sidecar before accessing the shared content-addressed blob. Unknown feedback, unreferenced hashes, malformed paths, and cross-community substitutions all collapse to `404`. -Only `GET` and `HEAD` are routed. Existing community `/media/*` authorization is -unchanged, including `BUZZ_REQUIRE_MEDIA_GET_AUTH`; the browser receives no -Blossom credential or reusable signed URL. Responses are uncached, `nosniff`, +Only `GET` and `HEAD` are routed. Community `/media/*` reads always require +Blossom authorization and relay membership; the browser receives no reusable +signed URL. Responses are uncached, `nosniff`, governed by a restrictive CSP, streamed from object storage, and non-previewable content retains attachment disposition. Successful reads produce a structured trace containing feedback ID, community ID, and attachment hash, but no feedback diff --git a/docs/multi-tenant-conformance.md b/docs/multi-tenant-conformance.md index 3cd56066eb..d8877b5931 100644 --- a/docs/multi-tenant-conformance.md +++ b/docs/multi-tenant-conformance.md @@ -49,7 +49,7 @@ Conformance obligations: | Workflows, runs, approvals, webhooks, schedules | Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. | Workflow definition's community from `req.community` at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. | Community-global workflow namespace; runs/approvals inherit workflow community. | `workflows`, `workflow_runs`, `workflow_approvals` include `community_id`; workflow id/token hash lookups are scoped; trigger event ids are scoped. | Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. | Existing workflow APIs and YAML remain unchanged in default community. | Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation. | | Search / FTS | Postgres FTS over the `events.search_tsv` generated `tsvector` column (GIN-indexed); searchable rows expose `id`, `content`, `kind`, `pubkey`, optional `channel_id`, `created_at`, tag terms; channel-less scope is `ChannelScope::ChannelLessOnly`; the relay refetches canonical events from Postgres by hit id. | Search query carries `req.community`; searchable rows carry `community_id`. | Community-global search results; operator-global FTS index infrastructure may be shared. | Every search query filters by `community_id`, BitmapAnd-ed with the GIN `@@` probe; refetch by `(community_id, event_id)`. | Every query carries `community_id` plus channel scope. `ChannelLessOnly` means channel-less within the community, not platform global. | One community produces the same search results as today. | Tests for same event id/content in A and B, deletion in A not deleting B. | | Redis pub/sub, presence, typing, and cache invalidation | Event fan-out uses `buzz:channel:{uuid}`; presence uses `buzz:presence:{pubkey}`; typing uses `buzz:typing:{channel_id}`; cache invalidation uses `buzz:cache-invalidate`. | Pub/sub calls receive `TenantContext` and derive keys from `community_id` plus channel/pubkey. | Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. | Redis keys include community: `buzz:{community}:channel:{uuid}`, `buzz:{community}:presence:{pubkey}`, `buzz:{community}:typing:{channel_id}`, and community-aware cache invalidation payloads/channels. | Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. | Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. | Add tests for same pubkey presence in two communities and same channel UUID collision in two communities. | -| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; public `GET/HEAD /media/{sha256.ext}` serves blobs; upload audit has `channel_id = None`. | Upload request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community. | Decide whether unauthenticated blob `GET` remains intentionally public; if not, reads need host-scoped auth/visibility checks. | +| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; `GET/HEAD /media/{sha256.ext}` requires a Blossom `t=get` auth event scoped to the serving host or the blob hash and binds the read to the header-resolved tenant, so a bare read is rejected before any storage lookup; upload audit has `channel_id = None`. | Upload and read request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community, but clients must now present read auth; there is no config flag that restores unauthenticated reads. | Resolved: blob reads are authenticated and host/tenant-scoped, not public. Remaining gap, deferred: a read is not gated on the channel ACL of the message the blob was attached to, so relay membership plus a known hash is sufficient. | | Git hosting / NIP-34 / object storage | Smart HTTP at `/git/{owner}/{repo}` hydrates from S3 object pointers; NIP-34 repo announcements use `d=repo-id`; pointer key is `repos/{owner}/{repo}/pointer`; git push emits kind:30618. | Git HTTP host gives `req.community`; NIP-98 URL and repo announcement community must agree. | Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. | Pointer/name keys include community, e.g. `repos/{community}/{owner}/{repo}/pointer`; NIP-34 replaceable coords include `community_id`; any repo-name registry is `(community_id, owner, repo)` or `(community_id, repo)` per product rule. | Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. | Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. | Add tests for same owner/repo in two communities and push in A not advancing B pointer. | | Mesh, agents, ACP/MCP, and CLI | Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. | The relay URL/host configured in the agent/CLI session selects community. | Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. | Any persisted agent profile/job/mesh status rows/events use `community_id`; Redis/presence/search keys follow the same community scoping. | A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. | Existing `BUZZ_RELAY_URL` continues to select the one default community. | Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile. | | Audit log and observability | One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. | Every tenant-observable audit entry is labeled with `req.community` or inherited community from the object being acted on. | Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. | `audit_log` key/sequence/head includes `community_id`; error/audit projection tables include `community_id`; uniqueness is `(community_id, seq)` and `(community_id, hash)` as appropriate. | Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. | Single-community audit verification still traverses one chain. | Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries. |