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 a78c0b4b42..213e5c36bf 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/.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/AGENTS.md b/AGENTS.md index 4b032edc9f..7506673eff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -905,10 +905,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/Cargo.lock b/Cargo.lock index b60855c66c..d76f500ce8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1263,11 +1263,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/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-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 267b2d21b5..c7bc31312e 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -104,10 +104,17 @@ pub struct Llm { auth: Arc, } +/// Connect-phase timeout applied to every outgoing LLM HTTP request. +/// +/// A 10-second budget is generous for a TLS + HTTP/2 handshake to a +/// well-provisioned gateway. Repeated connect timeouts indicate a +/// network/reachability problem, not a slow generation. +const LLM_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + impl Llm { pub fn new(cfg: &Config) -> Result { let http = Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) + .connect_timeout(LLM_CONNECT_TIMEOUT) .read_timeout(cfg.llm_timeout) .build() .map_err(|e| AgentError::Llm(format!("http: {e}")))?; @@ -353,7 +360,7 @@ impl Llm { async fn post_anthropic(&self, cfg: &Config, body: &Value) -> Result { let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); - post(&self.http, &url, body, false, |r| { + post(&self.http, &url, body, false, cfg.llm_timeout, |r| { r.header("x-api-key", &cfg.api_key) .header("anthropic-version", &cfg.anthropic_api_version) }) @@ -659,6 +666,7 @@ impl Llm { &url, body_ref, effective_model == MESH_VIRTUAL_MODEL_ID, + cfg.llm_timeout, |r| r.bearer_auth(&bearer), ) .await @@ -681,7 +689,7 @@ impl Llm { let mut bearer = self.auth.bearer().await?; let mut refreshed = false; loop { - match openrouter_post(&self.http, &url, body, &bearer).await { + match openrouter_post(&self.http, &url, body, &bearer, cfg.llm_timeout).await { Err(AgentError::LlmAuth(_)) if !refreshed => { refreshed = true; let new_bearer = self.auth.refresh_now(&bearer).await?; @@ -1731,6 +1739,85 @@ fn is_retryable_transport_error(e: &reqwest::Error) -> bool { e.is_timeout() || e.is_connect() || e.is_request() } +/// Which phase of an HTTP exchange produced a timeout error. +/// +/// Used by `timeout_message` to choose the right factual description. +#[derive(Clone, Copy)] +enum TimeoutPhase { + /// Timeout before any response bytes — transport/send phase. + Transport, + /// Timeout after headers were received, while reading body chunks. + BodyRead, +} + +/// Pure function: build the human-readable timeout message for an LLM call. +/// +/// Takes the two reqwest flags and the applicable configured durations rather +/// than a `&reqwest::Error` so the flag-precedence logic can be tested without +/// any network involvement. +/// +/// `llm_timeout` is the configured `BUZZ_AGENT_LLM_TIMEOUT_SECS` value; it is +/// used for both read-timeout phases. Connect timeouts use `LLM_CONNECT_TIMEOUT`. +fn timeout_message( + is_connect: bool, + llm_timeout: std::time::Duration, + phase: TimeoutPhase, +) -> String { + if is_connect { + // Connect-phase timeout: the TCP/TLS handshake didn't complete. + // reqwest sets both is_timeout() and is_connect() for this case. + format!("connect timeout: no connection established within {LLM_CONNECT_TIMEOUT:?}") + } else { + match phase { + TimeoutPhase::Transport => format!( + "read timeout: no response bytes received within {llm_timeout:?} \ + (consider raising BUZZ_AGENT_LLM_TIMEOUT_SECS)" + ), + TimeoutPhase::BodyRead => format!( + "read timeout: no further response bytes received within {llm_timeout:?} \ + (consider raising BUZZ_AGENT_LLM_TIMEOUT_SECS)" + ), + } + } +} + +/// Produce a human-readable description of a transport-layer reqwest error. +/// +/// reqwest's `Display` for a `read_timeout` fire is the opaque +/// `"error sending request for url (...)"` — the same text as every other +/// pre-response failure — because the HTTP layer lumps them together. +/// We replace that string with a factual message that names which kind of +/// timeout fired, making it immediately obvious in logs whether the client +/// never connected or whether the server stopped sending bytes. +/// +/// `llm_timeout` is the `BUZZ_AGENT_LLM_TIMEOUT_SECS` value configured on the +/// HTTP client; it appears verbatim in the returned message. +fn classify_transport_error(e: &reqwest::Error, llm_timeout: std::time::Duration) -> String { + if e.is_timeout() { + timeout_message(e.is_connect(), llm_timeout, TimeoutPhase::Transport) + } else { + format!("transport: {e}") + } +} + +/// Produce a human-readable description of an error that occurred while +/// reading response body chunks (`resp.chunk()`). +/// +/// A timeout here means headers and possibly body bytes arrived but the +/// stream then stalled past the read timeout. Any other body-decode failure +/// preserves the `"body read: ..."` prefix expected by callers and existing +/// tests. +/// +/// `llm_timeout` is the `BUZZ_AGENT_LLM_TIMEOUT_SECS` value configured on the +/// HTTP client; it appears verbatim in the returned message. +fn classify_body_read_error(e: &reqwest::Error, llm_timeout: std::time::Duration) -> String { + if e.is_timeout() { + timeout_message(e.is_connect(), llm_timeout, TimeoutPhase::BodyRead) + } else { + format!("body read: {e}") + } +} + fn is_unsupported_image_input_error(body: &str) -> bool { body.to_ascii_lowercase() .contains("no endpoints found that support image input") @@ -1816,6 +1903,7 @@ async fn post( url: &str, body: &Value, detect_mesh_fallback: bool, + read_timeout: std::time::Duration, apply: F, ) -> Result where @@ -1840,6 +1928,7 @@ where attempt = attempt + 1, max_attempts = MAX_RETRIES, error = %e, + is_timeout = e.is_timeout(), "llm: transport error, retrying" ); backoff_with_jitter(attempt).await; @@ -1848,7 +1937,7 @@ where return Err(PostError::Agent(terminal_llm_error( call_start.elapsed(), attempt + 1, - &format!("transport: {e}"), + &classify_transport_error(&e, read_timeout), ))); } }; @@ -1944,7 +2033,7 @@ where return Err(PostError::Agent(terminal_llm_error( call_start.elapsed(), attempt + 1, - &format!("body read: {e}"), + &classify_body_read_error(&e, read_timeout), ))); } } @@ -2098,6 +2187,7 @@ async fn openrouter_post( url: &str, body: &Value, bearer: &str, + read_timeout: std::time::Duration, ) -> Result { let body_bytes = serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?; @@ -2120,6 +2210,7 @@ async fn openrouter_post( attempt = attempt + 1, max_attempts = MAX_RETRIES, error = %e, + is_timeout = e.is_timeout(), "llm: openrouter transport error, retrying" ); backoff_with_jitter(attempt).await; @@ -2128,7 +2219,7 @@ async fn openrouter_post( return Err(terminal_llm_error( call_start.elapsed(), attempt + 1, - &format!("transport: {e}"), + &classify_transport_error(&e, read_timeout), )); } }; @@ -2263,7 +2354,7 @@ async fn openrouter_post( return Err(terminal_llm_error( call_start.elapsed(), attempt + 1, - &format!("body read: {e}"), + &classify_body_read_error(&e, read_timeout), )) } } @@ -4179,9 +4270,16 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let out = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .expect("post should succeed after retry"); + let out = post( + &client, + &url, + &serde_json::json!({}), + false, + Duration::from_secs(5), + |b| b, + ) + .await + .expect("post should succeed after retry"); assert_eq!(out, serde_json::json!({ "ok": true })); assert!( accepts.load(Ordering::SeqCst) >= 2, @@ -4243,9 +4341,16 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let out = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .expect("post should succeed after 499 retry"); + let out = post( + &client, + &url, + &serde_json::json!({}), + false, + Duration::from_secs(5), + |b| b, + ) + .await + .expect("post should succeed after 499 retry"); assert_eq!(out, serde_json::json!({ "ok": true })); assert!( accepts.load(Ordering::SeqCst) >= 2, @@ -4294,9 +4399,16 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = post(&client, &url, &serde_json::json!({}), false, |b| b) - .await - .unwrap_err(); + let err = post( + &client, + &url, + &serde_json::json!({}), + false, + Duration::from_secs(5), + |b| b, + ) + .await + .unwrap_err(); match &err { PostError::Agent(AgentError::Llm(msg)) => { assert!( @@ -4452,6 +4564,285 @@ mod tests { ); } + // ---- timeout_message (pure-function tests, no network) ------------------ + + /// Connect timeout (is_connect=true) wins regardless of phase and shows + /// the LLM_CONNECT_TIMEOUT value — never the read-timeout text. + #[test] + fn timeout_message_connect_true_shows_connect_timeout() { + let llm = std::time::Duration::from_secs(240); + for phase in [TimeoutPhase::Transport, TimeoutPhase::BodyRead] { + let msg = timeout_message(true, llm, phase); + assert!( + msg.starts_with("connect timeout:"), + "is_connect=true must start with 'connect timeout:': {msg}" + ); + // The configured connect timeout (10s) must appear verbatim. + assert!( + msg.contains("10s"), + "connect timeout must include the 10s configured value: {msg}" + ); + assert!( + !msg.contains("read timeout"), + "connect timeout must not mention 'read timeout': {msg}" + ); + assert!( + !msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "connect timeout must not reference the read-timeout config knob: {msg}" + ); + } + } + + /// Transport read-timeout (is_connect=false, Transport phase) shows the + /// configured llm_timeout value and the config-knob hint. + #[test] + fn timeout_message_transport_phase_shows_read_timeout_and_duration() { + let llm = std::time::Duration::from_secs(240); + let msg = timeout_message(false, llm, TimeoutPhase::Transport); + assert!( + msg.starts_with("read timeout:"), + "transport read-timeout must start with 'read timeout:': {msg}" + ); + assert!( + msg.contains("240s"), + "transport read-timeout must include the 240s configured value: {msg}" + ); + assert!( + msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "transport read-timeout must reference the config knob: {msg}" + ); + assert!( + !msg.contains("connect timeout"), + "transport read-timeout must not say 'connect timeout': {msg}" + ); + } + + /// Body-read timeout (BodyRead phase) says "no further response bytes" + /// (headers and possibly partial body already arrived) and shows the value. + #[test] + fn timeout_message_body_read_phase_says_no_further_bytes_and_duration() { + let llm = std::time::Duration::from_secs(300); + let msg = timeout_message(false, llm, TimeoutPhase::BodyRead); + assert!( + msg.starts_with("read timeout:"), + "body-read timeout must start with 'read timeout:': {msg}" + ); + assert!( + msg.contains("no further"), + "body-read timeout must say 'no further': {msg}" + ); + assert!( + msg.contains("300s"), + "body-read timeout must include the 300s configured value: {msg}" + ); + assert!( + msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "body-read timeout must reference the config knob: {msg}" + ); + } + + /// A non-default duration threads through correctly — verifies the value + /// is not hard-coded anywhere in the pure function. + #[test] + fn timeout_message_duration_is_not_hardcoded() { + let msg = timeout_message( + false, + std::time::Duration::from_secs(600), + TimeoutPhase::Transport, + ); + assert!( + msg.contains("600s"), + "transport read-timeout must reflect the supplied 600s value: {msg}" + ); + assert!( + !msg.contains("240s"), + "must not hard-code 240s when 600s was supplied: {msg}" + ); + } + + // ---- classify_transport_error / classify_body_read_error (reqwest integration) -- + + /// A real loopback read-timeout must produce a message rooted at "read + /// timeout:" that contains the configured value — and must NOT use reqwest's + /// opaque "error sending request" string. + /// + /// This is the one test that requires real network I/O (loopback only) to + /// verify that reqwest actually sets is_timeout() for the scenario in which + /// Buzz agents stall (server connected but emitting no bytes). + #[tokio::test] + async fn classify_transport_error_read_timeout_is_loopback_verified() { + use tokio::net::TcpListener; + + let llm_timeout = std::time::Duration::from_millis(50); + // Bind and never accept — TCP connect succeeds, no bytes follow. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let _listener = listener; // keep alive so connect succeeds + + let client = reqwest::Client::builder() + .read_timeout(llm_timeout) + .build() + .unwrap(); + + let err = client + .get(format!("http://{addr}/")) + .send() + .await + .expect_err("must time out"); + + // Preconditions: verify reqwest's classification before asserting our output. + assert!( + err.is_timeout(), + "precondition: reqwest must report is_timeout" + ); + assert!( + !err.is_connect(), + "precondition: read timeout must not set is_connect" + ); + + let msg = classify_transport_error(&err, llm_timeout); + assert!( + msg.starts_with("read timeout:"), + "read timeout must start with 'read timeout:': {msg}" + ); + assert!( + msg.contains("50ms"), + "read timeout must include the configured 50ms value: {msg}" + ); + assert!( + msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "read timeout must name the config knob: {msg}" + ); + assert!( + !msg.contains("error sending request"), + "read timeout must not use the opaque reqwest string: {msg}" + ); + } + + /// Non-timeout transport errors preserve the original reqwest error text. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn classify_transport_error_non_timeout_preserves_reqwest_text() { + use tokio::net::TcpListener; + + // Accept-then-close: keep the listener alive so the endpoint stays + // owned throughout, spawn a task that accepts exactly one connection + // and immediately drops the socket. Produces a deterministic + // non-timeout reqwest error (request-class, not is_timeout()) while + // the test holds exclusive ownership of the address — no released-port + // race possible. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + if let Ok((sock, _)) = listener.accept().await { + drop(sock); // close immediately, no response written + } + }); + + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_millis(200)) + .build() + .unwrap(); + + let err = client + .get(format!("http://{addr}/")) + .send() + .await + .expect_err("must fail: server closes connection before response"); + + assert!( + !err.is_timeout(), + "precondition: connection-closed is not a timeout: {err}" + ); + + assert_eq!( + classify_transport_error(&err, std::time::Duration::from_secs(240)), + format!("transport: {err}") + ); + } + + /// A body-read timeout fires after headers arrive but before the body is + /// complete. A loopback server sends an HTTP 200 with a declared content- + /// length larger than the payload it actually delivers; the client reads + /// one chunk, then stalls until the read timeout fires on the second chunk. + /// + /// Asserts the exact wording, configured duration, and config-knob hint. + /// Also covers the non-timeout fallback via classify_body_read_error. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn classify_body_read_error_timeout_says_no_further_bytes() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let llm_timeout = std::time::Duration::from_millis(100); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Server: accept once, send headers + one body chunk, then hang. + tokio::spawn(async move { + if let Ok((mut sock, _)) = listener.accept().await { + // Consume the request. + let mut buf = [0u8; 512]; + let _ = sock.read(&mut buf).await; + // Declare 1 KiB body, send 4 bytes, then do nothing. + let _ = sock + .write_all( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/json\r\n\ + Content-Length: 1024\r\n\ + \r\n\ + test", + ) + .await; + // Hold the connection open so the client read-timeouts rather + // than seeing EOF. + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + }); + + let client = reqwest::Client::builder() + .read_timeout(llm_timeout) + .build() + .unwrap(); + + let resp = client + .get(format!("http://{addr}/")) + .send() + .await + .expect("headers must arrive before timeout"); + + // Consume the response body — this is where the timeout fires. + let err = resp.bytes().await.expect_err("body read must time out"); + + assert!( + err.is_timeout(), + "precondition: reqwest must report is_timeout for body stall" + ); + + // ---- classify_body_read_error: timeout path ---- + let msg = classify_body_read_error(&err, llm_timeout); + assert!( + msg.starts_with("read timeout:"), + "body-read timeout must start with 'read timeout:': {msg}" + ); + assert!( + msg.contains("no further"), + "body-read timeout must say 'no further': {msg}" + ); + assert!( + msg.contains("100ms"), + "body-read timeout must include the configured 100ms value: {msg}" + ); + assert!( + msg.contains("BUZZ_AGENT_LLM_TIMEOUT_SECS"), + "body-read timeout must reference the config knob: {msg}" + ); + + // ---- classify_body_read_error: non-timeout fallback (pure, no I/O) ---- + // We can't produce a real non-timeout body error without real I/O, but + // the pure-function path is identical to classify_transport_error's + // non-timeout fallback and is covered by the pure tests above. + } + // ---- usage / input-token extraction ------------------------------------- #[test] @@ -6437,9 +6828,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("403") && s.contains("model flagged by moderation")), "403 must surface as AgentError::Llm with status+body, not LlmAuth: got {err:?}" @@ -6464,9 +6861,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("credits exhausted")), "got {err:?}" @@ -6494,9 +6897,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")), "parameter-routing 404 must not be reported as a missing model: got {err:?}" @@ -6522,9 +6931,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("support image input")), "image rejection must reach the history-recovery path: got {err:?}" @@ -6552,9 +6967,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::LlmModelNotFound(s) if s.contains("404") && s.contains("vendor/nonexistent-model")), "a model-level 404 must stay LlmModelNotFound: got {err:?}" @@ -6577,9 +6998,15 @@ mod tests { .build() .unwrap(); let before = std::time::Instant::now(); - let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("second attempt succeeds"); + let out = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("second attempt succeeds"); assert_eq!(out["choices"][0]["message"]["content"], "ok"); assert!( before.elapsed() >= Duration::from_secs(1), @@ -6604,9 +7031,15 @@ mod tests { .await; let http = Client::builder().build().unwrap(); let before = tokio::time::Instant::now(); - let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("second attempt succeeds"); + let out = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("second attempt succeeds"); assert_eq!(out["choices"][0]["message"]["content"], "ok"); assert!( before.elapsed() <= Duration::from_secs(RETRY_AFTER_CAP_SECS + 5), @@ -6628,9 +7061,15 @@ mod tests { .timeout(Duration::from_secs(30)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")), "got {err:?}" @@ -6655,9 +7094,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("200 succeeds"); + openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("200 succeeds"); let headers = captured.lock().await; let header_str = headers .first() @@ -6686,9 +7131,15 @@ mod tests { .timeout(Duration::from_secs(30)) .build() .unwrap(); - let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("retry after 499 should succeed"); + let out = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("retry after 499 should succeed"); assert_eq!(out["choices"][0]["message"]["content"], "ok"); assert_eq!( attempts.load(std::sync::atomic::Ordering::SeqCst), @@ -6711,9 +7162,15 @@ mod tests { .timeout(Duration::from_secs(30)) .build() .unwrap(); - let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .expect("retry succeeds"); + let out = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .expect("retry succeeds"); assert_eq!(out["choices"][0]["message"]["content"], "ok"); assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); } @@ -6762,9 +7219,15 @@ mod tests { .timeout(Duration::from_secs(5)) .build() .unwrap(); - let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") - .await - .unwrap_err(); + let err = openrouter_post( + &http, + &format!("{url}/x"), + &json!({}), + "key", + Duration::from_secs(5), + ) + .await + .unwrap_err(); assert!( matches!(&err, AgentError::Llm(s) if s.contains("body read")), "truncated body must surface as AgentError::Llm with 'body read': got {err:?}" diff --git a/crates/buzz-core/src/pairing/session.rs b/crates/buzz-core/src/pairing/session.rs index 431b87fcc0..0d43d4d827 100644 --- a/crates/buzz-core/src/pairing/session.rs +++ b/crates/buzz-core/src/pairing/session.rs @@ -223,6 +223,48 @@ impl PairingSession { Ok(event) } + /// (Source) Process a payload sent back by the target. + /// + /// This is used by recovery flows where the QR-displaying device requests + /// a secret from an already-authorized scanning device. + pub fn handle_return_payload( + &mut self, + event: &Event, + ) -> Result<(PayloadType, Zeroizing), PairingError> { + self.check_expired()?; + self.expect_state(SessionState::Transferring)?; + self.expect_role(Role::Source)?; + self.validate_event_from_peer(event)?; + + let msg = self.decrypt_message(event)?; + match msg { + PairingMessage::Payload { + payload_type, + payload, + } => { + self.state = SessionState::PayloadExchanged; + self.record_event(event); + Ok((payload_type, Zeroizing::new(payload))) + } + other => Err(unexpected("payload", &other)), + } + } + + /// (Source) Report whether a returned payload was imported successfully. + pub fn send_source_complete(&mut self, success: bool) -> Result { + self.check_expired()?; + self.expect_state(SessionState::PayloadExchanged)?; + self.expect_role(Role::Source)?; + + let event = self.build_event(&PairingMessage::Complete { success })?; + self.state = if success { + SessionState::Completed + } else { + SessionState::Aborted + }; + Ok(event) + } + /// (Source) Build the payload event carrying the secret. pub fn send_payload( &mut self, @@ -821,6 +863,74 @@ mod tests { assert_eq!(source.state(), SessionState::Completed); } + /// Reverse happy-path: the scanning target returns an nsec and the source + /// reports the import result. Duplicate payloads remain single-use. + #[test] + fn reverse_payload_flow_is_single_use() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + let source_sas = source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("source confirm"); + assert_eq!( + target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"), + source_sas + ); + target.confirm_target_sas().expect("target confirm"); + + let payload = target + .build_event(&PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "nsec1recovered".into(), + }) + .expect("return payload"); + let (payload_type, secret) = source + .handle_return_payload(&payload) + .expect("handle return payload"); + assert_eq!(payload_type, PayloadType::Nsec); + assert_eq!(*secret, "nsec1recovered"); + assert_eq!(source.state(), SessionState::PayloadExchanged); + assert!(source.handle_return_payload(&payload).is_err()); + + let complete = source.send_source_complete(true).expect("source complete"); + assert_eq!(source.state(), SessionState::Completed); + assert!(matches!( + target.decrypt_message(&complete).expect("decrypt complete"), + PairingMessage::Complete { success: true } + )); + } + + #[test] + fn reverse_payload_import_failure_aborts_both_peers() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("source confirm"); + target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + let payload = target + .build_event(&PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "invalid".into(), + }) + .expect("return payload"); + source + .handle_return_payload(&payload) + .expect("handle return payload"); + + let complete = source + .send_source_complete(false) + .expect("failure complete"); + assert_eq!(source.state(), SessionState::Aborted); + assert!(matches!( + target.decrypt_message(&complete).expect("decrypt complete"), + PairingMessage::Complete { success: false } + )); + } + /// State machine rejects out-of-order operations. #[test] fn reject_out_of_order_operations() { 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/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/identity.rs b/desktop/src-tauri/src/commands/identity.rs index bddf2e725a..ec2357b85e 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -404,7 +404,7 @@ pub async fn import_identity( /// as a command `Err` would claim a half-applied import that actually /// succeeded. The leftover blob is still passphrase-encrypted and is /// replaced by the next backup creation; we log and move on. -fn commit_imported_identity( +pub(crate) fn commit_imported_identity( state: &AppState, data_dir: &std::path::Path, keys: nostr::Keys, 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/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index fc874a0150..aedd67854c 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -9,7 +9,7 @@ use buzz_core_pkg::pairing::types::{AbortReason, PayloadType}; use futures_util::{SinkExt, StreamExt}; use nostr::ToBech32; use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tokio_util::sync::CancellationToken; @@ -33,16 +33,36 @@ struct PairingErrorPayload { message: String, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PairingMode { + SendIdentity, + RecoverIdentity, +} + +#[derive(Clone)] +struct PairingTaskContext { + mode: PairingMode, + generation: Arc, + generation_fence: Arc>, + task_generation: u64, +} + /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, generation: Arc, + /// Linearizes cancellation/replacement against recovered identity commits. + generation_fence: Arc>, + /// Serializes session setup so an older start cannot resume after relay + /// discovery and overwrite a newer session's shared state. + start_lock: tokio::sync::Mutex<()>, cancel: std::sync::Mutex>, /// Send JSON-serialized events to the background WS task for relay publication. outbound_tx: std::sync::Mutex>>, /// Pre-built payload string (contains nsec) to send after SAS confirmation. /// Wrapped in Zeroizing so the nsec is cleared from memory on drop. payload: std::sync::Mutex>>, + mode: Arc>, } impl PairingHandle { @@ -50,9 +70,12 @@ impl PairingHandle { Self { session: Arc::new(tokio::sync::Mutex::new(None)), generation: Arc::new(AtomicU64::new(0)), + generation_fence: Arc::new(std::sync::Mutex::new(())), + start_lock: tokio::sync::Mutex::new(()), cancel: std::sync::Mutex::new(None), outbound_tx: std::sync::Mutex::new(None), payload: std::sync::Mutex::new(None), + mode: Arc::new(std::sync::Mutex::new(PairingMode::SendIdentity)), } } @@ -63,21 +86,36 @@ impl PairingHandle { } } -/// Start a NIP-AB pairing session as the source device. -/// -/// Creates a `PairingSession`, connects to the relay, and returns the -/// `nostrpair://` QR URI for the frontend to display. The mobile peer will -/// receive the desktop's nsec (NIP-OA auth — no token minting needed). +/// Start a NIP-AB pairing session that sends this desktop identity to mobile. #[tauri::command] pub async fn start_pairing( app: AppHandle, state: State<'_, AppState>, pairing: State<'_, PairingHandle>, ) -> Result { - let task_generation = pairing - .generation - .fetch_add(1, Ordering::SeqCst) - .wrapping_add(1); + start_pairing_session(app, state, pairing, PairingMode::SendIdentity).await +} + +/// Start a recovery session. The fresh desktop shows the QR and receives the +/// full identity from an already-authorized phone after both users approve SAS. +#[tauri::command] +pub async fn start_identity_recovery_pairing( + app: AppHandle, + state: State<'_, AppState>, + pairing: State<'_, PairingHandle>, +) -> Result { + start_pairing_session(app, state, pairing, PairingMode::RecoverIdentity).await +} + +async fn start_pairing_session( + app: AppHandle, + state: State<'_, AppState>, + pairing: State<'_, PairingHandle>, + mode: PairingMode, +) -> Result { + let _start_guard = pairing.start_lock.lock().await; + let task_generation = + invalidate_pairing_generation(&pairing.generation, &pairing.generation_fence)?; if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } @@ -86,54 +124,52 @@ pub async fn start_pairing( let mut session = pairing.session.lock().await; *session = None; } - - let keys = state.signing_keys()?; - let nsec = keys - .secret_key() - .to_bech32() - .map_err(|e| format!("encode nsec: {e}"))?; - let pubkey_hex = keys.public_key().to_hex(); + *pairing.mode.lock().map_err(|e| e.to_string())? = mode; + *pairing.payload.lock().map_err(|e| e.to_string())? = None; let ws_url = relay_ws_url_with_override(&state); let http_url = relay_api_base_url_with_override(&state); - - // NIP-43 relays gate connections on membership, so an unpaired peer can't - // reach the main relay yet — it must go through the /pair sidecar. Open - // relays (no NIP-43) accept the peer directly. We key off the relay's - // own NIP-11 declaration of NIP-43 support rather than `auth_required`, - // which is also true for plain NIP-42 / NIP-OA relays where the main - // relay is reachable. let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?; - let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone()); - let qr_uri = encode_qr(&qr_payload); + let mut qr_uri = encode_qr(&qr_payload); + if mode == PairingMode::RecoverIdentity { + qr_uri.push_str("&mode=recover"); + } - let payload_json = serde_json::json!({ - "relayUrl": http_url, - "pubkey": pubkey_hex, - "nsec": nsec, - }); + if mode == PairingMode::SendIdentity { + let keys = state.signing_keys()?; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("encode nsec: {e}"))?; + let payload_json = serde_json::json!({ + "relayUrl": http_url, + "pubkey": keys.public_key().to_hex(), + "nsec": nsec, + }); + *pairing.payload.lock().map_err(|e| e.to_string())? = + Some(Zeroizing::new(payload_json.to_string())); + } { - let mut s = pairing.session.lock().await; - *s = Some(session); + let mut active = pairing.session.lock().await; + *active = Some(session); } - *pairing.payload.lock().map_err(|e| e.to_string())? = - Some(Zeroizing::new(payload_json.to_string())); let (outbound_tx, outbound_rx) = mpsc::channel::(16); let cancel = CancellationToken::new(); - *pairing.outbound_tx.lock().map_err(|e| e.to_string())? = Some(outbound_tx); *pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone()); - let session_arc = Arc::clone(&pairing.session); - let generation = Arc::clone(&pairing.generation); tauri::async_runtime::spawn(pairing_ws_task( pairing_relay_url, - session_arc, - generation, - task_generation, + Arc::clone(&pairing.session), + PairingTaskContext { + mode, + generation: Arc::clone(&pairing.generation), + generation_fence: Arc::clone(&pairing.generation_fence), + task_generation, + }, cancel, outbound_rx, app, @@ -161,27 +197,30 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() tx.send(sas_confirm_json) .await - .map_err(|_| "failed to send sas-confirm")?; - - let payload = pairing - .payload - .lock() - .map_err(|e| e.to_string())? - .take() - .ok_or("no payload prepared")?; + .map_err(|_| "Pairing code expired. Create a new code and try again.")?; - let payload_json = { - let mut guard = pairing.session.lock().await; - let session = guard.as_mut().ok_or("no active pairing session")?; - let event = session - .send_payload(PayloadType::Custom, payload) - .map_err(|e| e.to_string())?; - event_to_relay_json(&event) - }; - - tx.send(payload_json) - .await - .map_err(|_| "failed to send payload")?; + let mode = *pairing.mode.lock().map_err(|e| e.to_string())?; + if mode == PairingMode::SendIdentity { + let payload = pairing + .payload + .lock() + .map_err(|e| e.to_string())? + .take() + .ok_or("no payload prepared")?; + + let payload_json = { + let mut guard = pairing.session.lock().await; + let session = guard.as_mut().ok_or("no active pairing session")?; + let event = session + .send_payload(PayloadType::Custom, payload) + .map_err(|e| e.to_string())?; + event_to_relay_json(&event) + }; + + tx.send(payload_json) + .await + .map_err(|_| "failed to send payload")?; + } Ok(()) } @@ -189,6 +228,14 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() /// Cancel the active pairing session. #[tauri::command] pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), String> { + // Invalidate the task before waiting for its session lock. Recovery may be + // blocked on identity persistence after releasing this lock, and must see + // cancellation before crossing the durable commit boundary. + invalidate_pairing_generation(&pairing.generation, &pairing.generation_fence)?; + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { + token.cancel(); + } + let abort_json = { let mut guard = pairing.session.lock().await; if let Some(session) = guard.as_mut() { @@ -213,11 +260,6 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } } - pairing.generation.fetch_add(1, Ordering::SeqCst); - - if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { - token.cancel(); - } pairing.clear(); { @@ -231,8 +273,7 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str async fn pairing_ws_task( relay_url: String, session: Arc>>, - generation: Arc, - task_generation: u64, + context: PairingTaskContext, cancel: CancellationToken, mut outbound_rx: mpsc::Receiver, app: AppHandle, @@ -240,26 +281,24 @@ async fn pairing_ws_task( if let Err(e) = pairing_ws_task_inner( &relay_url, &session, - &generation, - task_generation, + &context, &cancel, &mut outbound_rx, &app, ) .await { - if pairing_task_is_current(&generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); } } - clear_pairing_session_if_current(&session, &generation, task_generation).await; + clear_pairing_session_if_current(&session, &context.generation, context.task_generation).await; } async fn pairing_ws_task_inner( relay_url: &str, session: &Arc>>, - generation: &AtomicU64, - task_generation: u64, + context: &PairingTaskContext, cancel: &CancellationToken, outbound_rx: &mut mpsc::Receiver, app: &AppHandle, @@ -290,14 +329,14 @@ async fn pairing_ws_task_inner( tokio::pin!(hard_timeout); loop { - if !pairing_task_is_current(generation, task_generation) { + if !pairing_task_is_current(&context.generation, context.task_generation) { break; } tokio::select! { _ = cancel.cancelled() => break, _ = &mut hard_timeout => { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-error", PairingErrorPayload { message: "Session timed out".into(), }); @@ -317,7 +356,7 @@ async fn pairing_ws_task_inner( let Message::Text(text) = msg else { continue }; if let Some(event) = parse_relay_event(text.as_str(), "pair") { - if !pairing_task_is_current(generation, task_generation) { + if !pairing_task_is_current(&context.generation, context.task_generation) { break; } @@ -325,7 +364,7 @@ async fn pairing_ws_task_inner( let Some(s) = guard.as_mut() else { break }; if let Ok(reason) = s.handle_abort(&event) { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-aborted", PairingAbortedPayload { reason: format!("{reason:?}"), }); @@ -334,28 +373,83 @@ async fn pairing_ws_task_inner( } if let Ok(sas) = s.handle_offer(&event) { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); } continue; } - match s.handle_complete(&event) { - Ok(()) => { - if pairing_task_is_current(generation, task_generation) { - let _ = app.emit("pairing-complete", serde_json::json!({})); + if context.mode == PairingMode::RecoverIdentity { + if let Ok((payload_type, payload)) = s.handle_return_payload(&event) { + if let Err(message) = validate_recovery_payload_type(payload_type) { + let complete = s + .send_source_complete(false) + .map_err(|e| e.to_string())?; + write + .send(Message::Text(event_to_relay_json(&complete).into())) + .await + .map_err(|e| format!("publish complete failed: {e}"))?; + if pairing_task_is_current( + &context.generation, + context.task_generation, + ) { + let _ = app.emit( + "pairing-error", + PairingErrorPayload { message }, + ); + } + break; } + + let payload = payload; + drop(guard); + + let imported = import_recovered_identity( + app, + payload, + &context.generation, + &context.generation_fence, + context.task_generation, + ) + .await; + let success = imported.is_ok(); + let complete = { + let mut guard = session.lock().await; + if !pairing_task_is_current( + &context.generation, + context.task_generation, + ) { + break; + } + let Some(s) = guard.as_mut() else { break }; + s.send_source_complete(success) + .map_err(|e| e.to_string())? + }; + let completion_result = write + .send(Message::Text(event_to_relay_json(&complete).into())) + .await + .map_err(|e| format!("publish complete failed: {e}")); + finish_recovery(imported, completion_result, context, app)?; break; } - Err(ref e) if format!("{e}").contains("success=false") => { - if pairing_task_is_current(generation, task_generation) { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Mobile device reported failure importing credentials".into(), - }); + } else { + match s.handle_complete(&event) { + Ok(()) => { + if pairing_task_is_current(&context.generation, context.task_generation) { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } + break; } - break; + Err(ref e) if format!("{e}").contains("success=false") => { + if pairing_task_is_current(&context.generation, context.task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Mobile device reported failure importing credentials".into(), + }); + } + break; + } + Err(_) => {} } - Err(_) => {} } } } @@ -365,10 +459,111 @@ async fn pairing_ws_task_inner( Ok(()) } +async fn import_recovered_identity( + app: &AppHandle, + nsec: Zeroizing, + generation: &Arc, + generation_fence: &Arc>, + task_generation: u64, +) -> Result<(), String> { + let app = app.clone(); + let generation = Arc::clone(generation); + let generation_fence = Arc::clone(generation_fence); + tokio::task::spawn_blocking(move || { + let keys = nostr::Keys::parse(nsec.trim()) + .map_err(|e| format!("Phone sent an invalid identity: {e}"))?; + let state = app.state::(); + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + commit_recovery_if_current(&generation, &generation_fence, task_generation, || { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; + let key_path = data_dir.join("identity.key"); + crate::commands::identity::commit_imported_identity(&state, &data_dir, keys, |keys| { + let store = + crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + })?; + Ok(()) + }) + }) + .await + .map_err(|e| format!("identity recovery task failed: {e}"))? +} + +fn ensure_pairing_task_is_current( + generation: &AtomicU64, + task_generation: u64, +) -> Result<(), String> { + if pairing_task_is_current(generation, task_generation) { + Ok(()) + } else { + Err("Pairing session was superseded or cancelled".into()) + } +} + +fn invalidate_pairing_generation( + generation: &AtomicU64, + generation_fence: &std::sync::Mutex<()>, +) -> Result { + let _fence = generation_fence.lock().map_err(|e| e.to_string())?; + Ok(generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1)) +} + +fn commit_recovery_if_current( + generation: &AtomicU64, + generation_fence: &std::sync::Mutex<()>, + task_generation: u64, + commit: impl FnOnce() -> Result, +) -> Result { + let _fence = generation_fence.lock().map_err(|e| e.to_string())?; + ensure_pairing_task_is_current(generation, task_generation)?; + commit() +} + +fn recovery_result_after_completion( + imported: Result<(), String>, + _completion_result: Result<(), String>, +) -> Result<(), String> { + // Once the identity is durable, notifying the peer cannot roll it back. + imported +} + +fn finish_recovery( + imported: Result<(), String>, + completion_result: Result<(), String>, + context: &PairingTaskContext, + app: &AppHandle, +) -> Result<(), String> { + if !pairing_task_is_current(&context.generation, context.task_generation) { + return Ok(()); + } + + match recovery_result_after_completion(imported, completion_result) { + Ok(()) => { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } + Err(message) => { + let _ = app.emit("pairing-error", PairingErrorPayload { message }); + } + } + Ok(()) +} + fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool { generation.load(Ordering::SeqCst) == task_generation } +fn validate_recovery_payload_type(payload_type: PayloadType) -> Result<(), String> { + if payload_type == PayloadType::Nsec { + Ok(()) + } else { + Err("Mobile device sent an unsupported recovery payload".into()) + } +} + async fn clear_pairing_session_if_current( session: &Arc>>, generation: &AtomicU64, @@ -590,143 +785,9 @@ where } #[cfg(test)] -mod pairing_generation_tests { - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Arc; - - use super::{clear_pairing_session_if_current, PairingSession}; - - #[tokio::test] - async fn stale_task_does_not_clear_replacement_session() { - let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); - let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); - let generation = AtomicU64::new(1); - - generation.store(2, Ordering::SeqCst); - let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); - *session.lock().await = Some(replacement); - - clear_pairing_session_if_current(&session, &generation, 1).await; - - assert!(session.lock().await.is_some()); - } - - #[tokio::test] - async fn current_task_clears_its_session() { - let (active, _) = PairingSession::new_source("ws://active.example".to_string()); - let session = Arc::new(tokio::sync::Mutex::new(Some(active))); - let generation = AtomicU64::new(3); - - clear_pairing_session_if_current(&session, &generation, 3).await; - - assert!(session.lock().await.is_none()); - } -} +#[path = "pairing_generation_tests.rs"] +mod pairing_generation_tests; #[cfg(test)] -mod pairing_relay_tests { - use super::{ - pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, - }; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - #[tokio::test] - async fn live_nip11_probe_discovers_configured_pairing_relay() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind test NIP-11 server"); - let addr = listener.local_addr().expect("test server address"); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request"); - let mut request = vec![0; 2048]; - let bytes_read = stream.read(&mut request).await.expect("read request"); - let request = String::from_utf8_lossy(&request[..bytes_read]); - assert!(request.starts_with("GET / HTTP/1.1")); - assert!(request - .to_ascii_lowercase() - .contains("accept: application/nostr+json")); - - let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - stream - .write_all(response.as_bytes()) - .await - .expect("write response"); - }); - - assert_eq!( - probe_pairing_relay(&format!("ws://{addr}")).await, - PairingRelay::Configured("ws://127.0.0.1:5000".to_string()) - ); - server.await.expect("NIP-11 server task"); - } - - #[test] - fn configured_pairing_relay_takes_precedence_over_legacy_path() { - let document = serde_json::json!({ - "pairing_relay_url": "wss://pairing.buzz.xyz", - "supported_nips": [43] - }); - - assert_eq!( - pairing_relay_from_nip11(&document), - PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()) - ); - } - - #[test] - fn invalid_pairing_relay_url_falls_back_to_legacy_path() { - let document = serde_json::json!({ - "pairing_relay_url": "https://pairing.buzz.xyz", - "supported_nips": [43] - }); - - assert_eq!( - pairing_relay_from_nip11(&document), - PairingRelay::LegacyPath - ); - } - - #[test] - fn document_without_pairing_configuration_uses_main_relay() { - let document = serde_json::json!({ "supported_nips": [1, 11] }); - - assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); - } - - #[test] - fn configured_pairing_relay_resolves_to_configured_url() { - let resolved = resolve_pairing_relay_url( - "wss://flint.communities.buzz.xyz", - PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()), - ) - .expect("resolve configured pairing relay"); - - assert_eq!(resolved, "wss://pairing.buzz.xyz"); - } - - #[test] - fn legacy_pairing_relay_appends_pair_path() { - let resolved = resolve_pairing_relay_url( - "wss://flint.communities.buzz.xyz/community", - PairingRelay::LegacyPath, - ) - .expect("resolve legacy pairing relay"); - - assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); - } - - #[test] - fn main_relay_pairing_uses_main_relay_url() { - let resolved = resolve_pairing_relay_url( - "wss://sprout-oss.stage.blox.sqprod.co", - PairingRelay::MainRelay, - ) - .expect("resolve main pairing relay"); - - assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); - } -} +#[path = "pairing_relay_tests.rs"] +mod pairing_relay_tests; diff --git a/desktop/src-tauri/src/commands/pairing_generation_tests.rs b/desktop/src-tauri/src/commands/pairing_generation_tests.rs new file mode 100644 index 0000000000..8a2291ae86 --- /dev/null +++ b/desktop/src-tauri/src/commands/pairing_generation_tests.rs @@ -0,0 +1,129 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use super::{ + clear_pairing_session_if_current, commit_recovery_if_current, invalidate_pairing_generation, + recovery_result_after_completion, validate_recovery_payload_type, PairingHandle, + PairingSession, PayloadType, +}; + +#[tokio::test] +async fn overlapping_starts_are_serialized() { + let pairing = Arc::new(PairingHandle::new()); + let first_pairing = Arc::clone(&pairing); + let (locked_tx, locked_rx) = tokio::sync::oneshot::channel(); + let first = tokio::spawn(async move { + let _guard = first_pairing.start_lock.lock().await; + locked_tx.send(()).expect("signal acquired start lock"); + tokio::time::sleep(Duration::from_millis(50)).await; + }); + + locked_rx.await.expect("first start acquired lock"); + assert!(pairing.start_lock.try_lock().is_err()); + first.await.expect("first start task"); + assert!(pairing.start_lock.try_lock().is_ok()); +} + +#[test] +fn recovery_rejects_non_nsec_payloads() { + assert!(validate_recovery_payload_type(PayloadType::Nsec).is_ok()); + assert_eq!( + validate_recovery_payload_type(PayloadType::Custom).unwrap_err(), + "Mobile device sent an unsupported recovery payload" + ); +} + +#[test] +fn superseded_recovery_cannot_commit_identity() { + let generation = AtomicU64::new(2); + let committed = std::sync::atomic::AtomicBool::new(false); + + let generation_fence = std::sync::Mutex::new(()); + let result = commit_recovery_if_current(&generation, &generation_fence, 1, || { + committed.store(true, Ordering::SeqCst); + Ok(()) + }); + + assert_eq!( + result.unwrap_err(), + "Pairing session was superseded or cancelled" + ); + assert!(!committed.load(Ordering::SeqCst)); +} + +#[test] +fn invalidation_after_check_waits_for_identity_commit() { + let generation = Arc::new(AtomicU64::new(7)); + let generation_fence = Arc::new(std::sync::Mutex::new(())); + let (checked_tx, checked_rx) = std::sync::mpsc::channel(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel(); + let committed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let recovery_generation = Arc::clone(&generation); + let recovery_fence = Arc::clone(&generation_fence); + let recovery_committed = Arc::clone(&committed); + let recovery = std::thread::spawn(move || { + commit_recovery_if_current(&recovery_generation, &recovery_fence, 7, || { + checked_tx.send(()).expect("signal generation checked"); + finish_rx.recv().expect("release identity commit"); + recovery_committed.store(true, Ordering::SeqCst); + Ok(()) + }) + }); + + checked_rx.recv().expect("generation checked"); + let invalidation_generation = Arc::clone(&generation); + let invalidation_fence = Arc::clone(&generation_fence); + let (attempted_tx, attempted_rx) = std::sync::mpsc::channel(); + let (invalidated_tx, invalidated_rx) = std::sync::mpsc::channel(); + let invalidation = std::thread::spawn(move || { + attempted_tx.send(()).expect("signal invalidation attempt"); + let next = invalidate_pairing_generation(&invalidation_generation, &invalidation_fence) + .expect("invalidate generation"); + invalidated_tx.send(next).expect("signal invalidated"); + }); + + attempted_rx.recv().expect("invalidation attempted"); + assert!(invalidated_rx + .recv_timeout(Duration::from_millis(50)) + .is_err()); + assert!(!committed.load(Ordering::SeqCst)); + + finish_tx.send(()).expect("finish identity commit"); + recovery.join().expect("recovery task").unwrap(); + assert!(committed.load(Ordering::SeqCst)); + assert_eq!(invalidated_rx.recv().expect("invalidation completed"), 8); + invalidation.join().expect("invalidation task"); +} + +#[test] +fn completion_publish_failure_does_not_undo_successful_import() { + assert!(recovery_result_after_completion(Ok(()), Err("socket closed".into())).is_ok()); +} + +#[tokio::test] +async fn stale_task_does_not_clear_replacement_session() { + let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); + let generation = AtomicU64::new(1); + + generation.store(2, Ordering::SeqCst); + let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); + *session.lock().await = Some(replacement); + + clear_pairing_session_if_current(&session, &generation, 1).await; + + assert!(session.lock().await.is_some()); +} + +#[tokio::test] +async fn current_task_clears_its_session() { + let (active, _) = PairingSession::new_source("ws://active.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(active))); + let generation = AtomicU64::new(3); + + clear_pairing_session_if_current(&session, &generation, 3).await; + + assert!(session.lock().await.is_none()); +} diff --git a/desktop/src-tauri/src/commands/pairing_relay_tests.rs b/desktop/src-tauri/src/commands/pairing_relay_tests.rs new file mode 100644 index 0000000000..f0e765eb9c --- /dev/null +++ b/desktop/src-tauri/src/commands/pairing_relay_tests.rs @@ -0,0 +1,104 @@ +use super::{ + pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::test] +async fn live_nip11_probe_discovers_configured_pairing_relay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test NIP-11 server"); + let addr = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request"); + let mut request = vec![0; 2048]; + let bytes_read = stream.read(&mut request).await.expect("read request"); + let request = String::from_utf8_lossy(&request[..bytes_read]); + assert!(request.starts_with("GET / HTTP/1.1")); + assert!(request + .to_ascii_lowercase() + .contains("accept: application/nostr+json")); + + let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write response"); + }); + + assert_eq!( + probe_pairing_relay(&format!("ws://{addr}")).await, + PairingRelay::Configured("ws://127.0.0.1:5000".to_string()) + ); + server.await.expect("NIP-11 server task"); +} + +#[test] +fn configured_pairing_relay_takes_precedence_over_legacy_path() { + let document = serde_json::json!({ + "pairing_relay_url": "wss://pairing.buzz.xyz", + "supported_nips": [43] + }); + + assert_eq!( + pairing_relay_from_nip11(&document), + PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()) + ); +} + +#[test] +fn invalid_pairing_relay_url_falls_back_to_legacy_path() { + let document = serde_json::json!({ + "pairing_relay_url": "https://pairing.buzz.xyz", + "supported_nips": [43] + }); + + assert_eq!( + pairing_relay_from_nip11(&document), + PairingRelay::LegacyPath + ); +} + +#[test] +fn document_without_pairing_configuration_uses_main_relay() { + let document = serde_json::json!({ "supported_nips": [1, 11] }); + + assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); +} + +#[test] +fn configured_pairing_relay_resolves_to_configured_url() { + let resolved = resolve_pairing_relay_url( + "wss://flint.communities.buzz.xyz", + PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()), + ) + .expect("resolve configured pairing relay"); + + assert_eq!(resolved, "wss://pairing.buzz.xyz"); +} + +#[test] +fn legacy_pairing_relay_appends_pair_path() { + let resolved = resolve_pairing_relay_url( + "wss://flint.communities.buzz.xyz/community", + PairingRelay::LegacyPath, + ) + .expect("resolve legacy pairing relay"); + + assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); +} + +#[test] +fn main_relay_pairing_uses_main_relay_url() { + let resolved = resolve_pairing_relay_url( + "wss://sprout-oss.stage.blox.sqprod.co", + PairingRelay::MainRelay, + ) + .expect("resolve main pairing relay"); + + assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); +} 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/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7cb75c8112..e75ff18e36 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -880,6 +880,7 @@ pub fn run() { set_audio_output_device, get_audio_output_device, start_pairing, + start_identity_recovery_pairing, confirm_pairing_sas, cancel_pairing, apply_workspace, 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!( diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 811da043f9..0f311f3a65 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -706,6 +706,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { ; }) { + // 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/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/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/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/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..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"; @@ -10,7 +14,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 +35,7 @@ type UnifiedAgentsSectionProps = { agentsError: Error | null; isActionPending: boolean; isAgentsLoading: boolean; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; startingPersonaIds: ReadonlySet; onOpenAgentProfile: ( @@ -39,6 +43,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 +80,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { agentsError, isActionPending, isAgentsLoading, + restartingAgentPubkey, startingAgentPubkey, startingPersonaIds, onOpenAgentProfile, onOpenPersonaProfile, + onRestartAgent, onStartAgent, onStartPersona, personas, @@ -175,10 +182,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 +208,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 +223,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 +257,12 @@ function AgentPersonaCard({ agent, defaultModel, persona, + restartingAgentPubkey, startingAgentPubkey, startingPersonaIds, onOpenAgentProfile, onOpenPersonaProfile, + onRestartAgent, onStartAgent, onStartPersona, }: { @@ -258,6 +273,7 @@ function AgentPersonaCard({ agent: ManagedAgent | undefined; defaultModel: string; persona: AgentPersona; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; startingPersonaIds: ReadonlySet; onOpenAgentProfile: ( @@ -265,6 +281,7 @@ function AgentPersonaCard({ options?: ProfilePanelOpenOptions, ) => void; onOpenPersonaProfile: (persona: AgentPersona) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; }) { @@ -277,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 @@ -288,7 +305,7 @@ function AgentPersonaCard({ { 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 +371,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 +407,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,27 +442,12 @@ function StandaloneAgentCard({ Configuration missing - ) : agent.needsRestart ? ( - ) : null } /> ); } -function firstAvatarUrl( - ...candidates: Array -): string | null { - for (const candidate of candidates) { - const trimmed = candidate?.trim(); - if (trimmed) return trimmed; - } - return null; -} - function NewAgentCard({ isPending, onCreate, @@ -498,9 +511,11 @@ function CollapsibleAgentGroup({ agents, collapsed, defaultModel, + restartingAgentPubkey, startingAgentPubkey, onToggle, onOpenAgentProfile, + onRestartAgent, onStartAgent, }: { groupKey: string; @@ -508,12 +523,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 +556,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/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/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]); +} diff --git a/desktop/src/features/onboarding/machineOnboarding.ts b/desktop/src/features/onboarding/machineOnboarding.ts index 8bc4cadaa9..affdf72009 100644 --- a/desktop/src/features/onboarding/machineOnboarding.ts +++ b/desktop/src/features/onboarding/machineOnboarding.ts @@ -185,6 +185,12 @@ export function useMachineOnboardingState({ continuingPubkeyRef.current = pubkey; }, []); + const continueWithRecoveredIdentity = React.useCallback((pubkey: string) => { + continuingPubkeyRef.current = pubkey; + setBootedLost(false); + setBootedLocked(false); + }, []); + const reopen = React.useCallback(() => { clearMachineOnboardingCompletion(currentPubkey); setCompletedPubkey((pubkey) => (pubkey === currentPubkey ? null : pubkey)); @@ -224,7 +230,11 @@ export function useMachineOnboardingState({ continuingPubkeyRef.current !== currentPubkey) ) { stage = "blocking"; - } else if (identityLost || !hasCompletedCurrentPubkey) { + } else if ( + identityLost || + continuingPubkeyRef.current === currentPubkey || + !hasCompletedCurrentPubkey + ) { stage = "onboarding"; } else { stage = "ready"; @@ -233,6 +243,7 @@ export function useMachineOnboardingState({ return { complete, continueWithIdentity, + continueWithRecoveredIdentity, currentPubkey, identityLost, queryClient, diff --git a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx index 610c104d95..555d6e1365 100644 --- a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx +++ b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx @@ -46,6 +46,66 @@ const TIMELINE_BOTTOM_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map( }), ); +export function BackupFileUnlockPreview() { + const reduceMotion = useReducedMotion() ?? false; + + return ( +

+ +
+ {BACKUP_KEY_DOTS.map((dot) => ( + + ))} +
+ + +
+ ); +} + +function TimelineDots({ + reduceMotion, + transitions, +}: { + reduceMotion: boolean; + transitions: ReadonlyArray< + typeof TIMELINE_DOT_TRANSITION & { delay: number } + >; +}) { + return ( +
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ ); +} + /** * Decorative timeline shared by backup creation and encrypted-backup restore. * Backup creation reads key → password → lock; restore reads encrypted file → 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/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx new file mode 100644 index 0000000000..5cca7c1bf5 --- /dev/null +++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx @@ -0,0 +1,278 @@ +import * as React from "react"; +import { listen } from "@tauri-apps/api/event"; +import { + Check, + Copy, + LoaderCircle, + RefreshCw, + ShieldCheck, + TriangleAlert, + X, +} from "lucide-react"; + +import { cancelPairing, confirmPairingSas } from "@/shared/api/tauri"; +import { startIdentityRecoveryPairing } from "@/shared/api/tauriPairing"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; +import { Button } from "@/shared/ui/button"; +import { StyledQrCode } from "@/shared/ui/styled-qr-code"; + +type Step = "loading" | "qr" | "sas" | "receiving" | "done" | "error"; + +// Refresh before the pairing relay's two-minute connection cap so Desktop never +// leaves a code on screen after its publishing channel has closed. +const QR_REFRESH_MS = 90_000; + +function recoveryErrorMessage(message: string): string { + const normalized = message.toLowerCase(); + if ( + normalized.includes("sas-confirm") || + normalized.includes("relay connection closed") || + normalized.includes("websocket") || + normalized.includes("expired") || + normalized.includes("timed out") + ) { + return "This pairing code expired or lost its connection. Create a new code and try again."; + } + return message; +} + +export function IdentityRecoveryPairing({ + onRecovered, + onStepChange, +}: { + onRecovered: () => Promise; + onStepChange?: (step: Step) => void; +}) { + const [step, setStep] = React.useState("loading"); + const [qrUri, setQrUri] = React.useState(null); + const [sas, setSas] = React.useState(null); + const [error, setError] = React.useState(null); + const [copied, setCopied] = React.useState(false); + const active = React.useRef(true); + const copyTimer = React.useRef(null); + + React.useEffect(() => { + onStepChange?.(step); + }, [onStepChange, step]); + + const start = React.useCallback(async () => { + active.current = true; + setStep("loading"); + setError(null); + setSas(null); + setQrUri(null); + setCopied(false); + try { + setQrUri(await startIdentityRecoveryPairing()); + setStep("qr"); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Could not start recovery.", + ); + setStep("error"); + } + }, []); + + React.useEffect(() => { + void start(); + const unlisteners: Array<() => void> = []; + let disposed = false; + listen<{ sas: string }>("pairing-sas-received", ({ payload }) => { + if (!disposed && active.current) { + setSas(payload.sas); + setStep("sas"); + } + }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); + listen("pairing-complete", () => { + if (!disposed && active.current) { + active.current = false; + setStep("done"); + void onRecovered(); + } + }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); + listen<{ message: string }>("pairing-error", ({ payload }) => { + if (!disposed && active.current) { + active.current = false; + setError(recoveryErrorMessage(payload.message)); + setStep("error"); + } + }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); + listen<{ reason: string }>("pairing-aborted", ({ payload }) => { + if (!disposed && active.current) { + active.current = false; + setError(`Recovery stopped: ${payload.reason}`); + setStep("error"); + } + }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); + return () => { + disposed = true; + active.current = false; + for (const unlisten of unlisteners) unlisten(); + if (copyTimer.current !== null) window.clearTimeout(copyTimer.current); + void cancelPairing(); + }; + }, [onRecovered, start]); + + React.useEffect(() => { + if (step !== "qr") return; + const timer = window.setTimeout(() => void start(), QR_REFRESH_MS); + return () => window.clearTimeout(timer); + }, [start, step]); + + async function copyPairingCode() { + if (!qrUri) return; + try { + await writeTextToClipboard(qrUri); + setCopied(true); + if (copyTimer.current !== null) window.clearTimeout(copyTimer.current); + copyTimer.current = window.setTimeout(() => setCopied(false), 2_000); + } catch { + setError("Could not copy the pairing code. Try again."); + } + } + + async function deny() { + active.current = false; + await cancelPairing().catch(() => {}); + setError("The codes didn't match. Pairing was canceled."); + setStep("error"); + } + + async function confirm() { + setStep("receiving"); + try { + await confirmPairingSas(); + } catch (cause) { + if (!active.current) return; + setError( + recoveryErrorMessage( + cause instanceof Error + ? cause.message + : "Could not confirm recovery.", + ), + ); + setStep("error"); + } + } + + return ( +
+
+ {step === "qr" && qrUri ? ( + + ) : step === "sas" && sas ? ( +
+ +

+ Does this code match your phone? +

+
+

+ {sas.slice(0, 3)} {sas.slice(3)} +

+
+

+ This gives this desktop permanent access to your Buzz identity. + Only continue if you trust it. +

+
+ + +
+
+ ) : 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/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/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/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index e15f54316b..0d58f86458 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -4,11 +4,10 @@ import { Copy, LoaderCircle, RefreshCw, - ShieldCheck, TriangleAlert, - X, } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { @@ -16,15 +15,9 @@ import { confirmPairingSas, startPairing, } from "@/shared/api/tauri"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { StyledQrCode } from "@/shared/ui/styled-qr-code"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; @@ -39,6 +32,8 @@ type PairingStep = | "done" | "error"; +const PAIRING_CODE_DIGIT_POSITIONS = [0, 1, 2, 3, 4, 5] as const; + function pairingErrorMessage(error: unknown) { const message = error instanceof Error @@ -58,114 +53,189 @@ function isPairingSessionTimeout(message: string) { return message.toLowerCase().includes("session timed out"); } -function PairingStatusDialog({ - onClose, +function PairingStepIndicator({ + complete, + label, + testId, +}: { + complete: boolean; + label: string; + testId: string; +}) { + const shouldReduceMotion = useReducedMotion() ?? false; + const hiddenState = shouldReduceMotion + ? { opacity: 0 } + : { filter: "blur(2px)", opacity: 0, scale: 0.25 }; + const visibleState = shouldReduceMotion + ? { opacity: 1 } + : { filter: "blur(0px)", opacity: 1, scale: 1 }; + + return ( + + ); +} + +function PairingSteps({ step }: { step: PairingStep }) { + const hasScanned = + step === "sas" || step === "transferring" || step === "done"; + const hasConfirmed = step === "transferring" || step === "done"; + const isPaired = step === "done"; + + return ( +
    +
  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/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/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 ? ( +