From 02c1124286f83a77e219afeed2ddc24804529465 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 19:31:41 -0500 Subject: [PATCH 01/13] BUZZ-517: Guide old desktop users to upgrade Read the host requirement before each connection and keep upgrade guidance separate from retryable discovery failures. Preserve community and identity storage. Co-authored-by: Honor Signed-off-by: Brad Seiler --- desktop/README.md | 25 ++ desktop/biome.json | 8 +- desktop/playwright.config.ts | 1 + desktop/src-tauri/build.rs | 2 + desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/relay_entry_requirements.rs | 312 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/tests/fixtures/nip_fi/README.md | 10 + .../nip_fi/buzz_511_d3ae4935b_optional.json | 1 + .../buzz_511_d3ae4935b_optional_capable.json | 1 + .../nip_fi/buzz_511_d3ae4935b_required.json | 1 + desktop/src/app/App.tsx | 19 ++ desktop/src/app/RelayEntryGate.tsx | 100 ++++++ .../src/app/useAppShellLifecycleEffects.ts | 13 - .../api/relayClientResumeCursor.test.mjs | 2 + desktop/src/shared/api/relayClientSession.ts | 25 +- .../api/relayEntryRequirements.test.mjs | 83 +++++ .../src/shared/api/relayEntryRequirements.ts | 101 ++++++ desktop/src/testing/e2eBridge.ts | 33 +- .../tests/e2e/relay-entry-guidance.spec.ts | 157 +++++++++ desktop/tests/helpers/bridge.ts | 6 + 21 files changed, 875 insertions(+), 28 deletions(-) create mode 100644 desktop/src-tauri/src/commands/relay_entry_requirements.rs create mode 100644 desktop/src-tauri/tests/fixtures/nip_fi/README.md create mode 100644 desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional.json create mode 100644 desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional_capable.json create mode 100644 desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_required.json create mode 100644 desktop/src/app/RelayEntryGate.tsx create mode 100644 desktop/src/shared/api/relayEntryRequirements.test.mjs create mode 100644 desktop/src/shared/api/relayEntryRequirements.ts create mode 100644 desktop/tests/e2e/relay-entry-guidance.spec.ts diff --git a/desktop/README.md b/desktop/README.md index d53698a9d04..57ec4098bb3 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -23,3 +23,28 @@ Desktop chat shell with: - `src/shared` - reusable app-wide code (`ui`, `lib`, `styles`) - `src/features` - feature modules (vertical slices) - `src/app` - top-level app composition + +### Required-community guidance in the legacy app + +The legacy desktop reads public NIP-11 metadata before each WebSocket connection. +Only `requires_nip_fi: true` shows the upgrade card. Missing/false requirements +keep the existing connection flow; discovery failures show a retryable check +failure. Switching communities invalidates pending checks. The card does not +reset keys or remove saved communities. + +Packagers may set both `BUZZ_BUILD_REQUIRED_AUTH_UPGRADE_RELAY` (an exact `ws` or +`wss` community URL) and `BUZZ_BUILD_REQUIRED_AUTH_UPGRADE_URL` (an HTTPS installer +URL). The optional destination is compiled into the native command and exposed +only for the matching community on Apple Silicon macOS. Unconfigured builds and +other platforms show administrator guidance with no download link. Discovery +never supplies a trusted download or sign-in origin. + +Before release, exercise a native build with disposable identities: join a +required host, reopen its saved community, switch to an optional host, then +reconnect after its requirement changes. Verify timeout/503 recovery, Retry, +community navigation, the configured download action, and retained keys and +saved communities. Repeat in an unconfigured OSS build. Native HTTP tests and +browser tests cover different boundaries; neither substitutes for installed-app +and human acceptance. Refresh the pinned fixtures from the merged BUZZ-511 +producer before readiness. Host-policy rollout and maintenance-feed promotion +are separate release operations. diff --git a/desktop/biome.json b/desktop/biome.json index 12e778e4a92..4127ff5d308 100644 --- a/desktop/biome.json +++ b/desktop/biome.json @@ -1,3 +1,9 @@ { - "extends": ["../biome.json"] + "extends": ["../biome.json"], + "overrides": [ + { + "includes": ["src-tauri/tests/fixtures/nip_fi/*.json"], + "formatter": { "enabled": false } + } + ] } diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 61f77de46cb..4923a67928b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -156,6 +156,7 @@ export default defineConfig({ "**/global-agent-config-screenshots.spec.ts", "**/doctor-states.spec.ts", "**/onboarding-avatar-skip.spec.ts", + "**/relay-entry-guidance.spec.ts", "**/onboarding-backup.spec.ts", "**/onboarding-agent-defaults.spec.ts", "**/nostr-bind.spec.ts", diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 8b0e63f12bc..a0696c39e07 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -8,6 +8,8 @@ include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; fn main() { + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_REQUIRED_AUTH_UPGRADE_RELAY"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_REQUIRED_AUTH_UPGRADE_URL"); println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL"); println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP"); println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY"); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 01b1ddc8828..c9933babc07 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -65,6 +65,7 @@ mod project_git_workflow; mod project_repo_paths; mod project_terminal; mod qr_download; +mod relay_entry_requirements; mod relay_members; mod relay_reconnect; mod social; @@ -124,6 +125,7 @@ pub use project_git_recipient_notes::*; pub use project_git_workflow::*; pub use project_terminal::*; pub use qr_download::*; +pub use relay_entry_requirements::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; diff --git a/desktop/src-tauri/src/commands/relay_entry_requirements.rs b/desktop/src-tauri/src/commands/relay_entry_requirements.rs new file mode 100644 index 00000000000..85a4380330d --- /dev/null +++ b/desktop/src-tauri/src/commands/relay_entry_requirements.rs @@ -0,0 +1,312 @@ +//! Public host policy discovery for the legacy desktop. No identity or sign-in trust. +use futures_util::StreamExt; +use serde::Serialize; +use serde_json::Value; +use std::time::Duration; +use url::Url; + +const MAX_BYTES: usize = 256 * 1024; +const DEADLINE: Duration = Duration::from_secs(5); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayEntryRequirements { + required: bool, + upgrade_url: Option, +} + +fn discovery_url(relay: &str) -> Result { + let mut url = Url::parse(relay).map_err(|_| "Invalid community URL")?; + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.host_str().is_none() + { + return Err("Community URL must not contain credentials, query or fragment".into()); + } + let scheme = match url.scheme() { + "ws" => "http", + "wss" => "https", + _ => return Err("Community URL must use ws or wss".into()), + }; + url.set_scheme(scheme) + .map_err(|_| "Invalid community scheme")?; + Ok(url) +} + +fn configured_upgrade_url( + relay: &str, + binding: Option<&str>, + destination: Option<&str>, + supported_platform: bool, +) -> Option { + if !supported_platform { + return None; + } + let binding = discovery_url(binding?).ok()?; + if discovery_url(relay).ok()? != binding { + return None; + } + let destination = Url::parse(destination?).ok()?; + (destination.scheme() == "https" + && destination.host_str().is_some() + && destination.username().is_empty() + && destination.password().is_none() + && destination.fragment().is_none()) + .then(|| destination.to_string()) +} + +async fn fetch_policy(url: Url, deadline: Duration) -> Result { + // This dedicated client has no cookies, default auth or redirect capability. + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "Could not initialize community discovery")?; + tokio::time::timeout(deadline, async { + let response = client + .get(url) + .header("Accept", "application/nostr+json") + .header("Cache-Control", "no-cache") + .send() + .await + .map_err(|_| "Could not reach community discovery")?; + if response.status() != reqwest::StatusCode::OK { + return Err(format!( + "Community discovery returned HTTP {}", + response.status().as_u16() + )); + } + if response + .content_length() + .is_some_and(|size| size > MAX_BYTES as u64) + { + return Err("Community discovery response is too large".into()); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "Could not read community discovery")?; + if bytes.len() + chunk.len() > MAX_BYTES { + return Err("Community discovery response is too large".into()); + } + bytes.extend_from_slice(&chunk); + } + let doc: Value = + serde_json::from_slice(&bytes).map_err(|_| "Invalid community discovery document")?; + let object = doc + .as_object() + .ok_or("Invalid community discovery document")?; + match object.get("requires_nip_fi") { + None => Ok(false), + Some(Value::Bool(required)) => Ok(*required), + _ => Err("Invalid community authentication requirement".into()), + } + }) + .await + .map_err(|_| "Community discovery timed out".to_string())? +} + +#[tauri::command] +pub async fn fetch_relay_entry_requirements( + relay_url: String, +) -> Result { + let required = fetch_policy(discovery_url(&relay_url)?, DEADLINE).await?; + Ok(RelayEntryRequirements { + required, + upgrade_url: required + .then(|| { + configured_upgrade_url( + &relay_url, + option_env!("BUZZ_BUILD_REQUIRED_AUTH_UPGRADE_RELAY"), + option_env!("BUZZ_BUILD_REQUIRED_AUTH_UPGRADE_URL"), + cfg!(all(target_os = "macos", target_arch = "aarch64")), + ) + }) + .flatten(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + + fn serve(status: &str, headers: &str, body: &str) -> (Url, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let response = format!("HTTP/1.1 {status}\r\nConnection: close\r\n{headers}\r\n{body}"); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = vec![0; 4096]; + let size = socket.read(&mut request).unwrap(); + tx.send(String::from_utf8_lossy(&request[..size]).to_string()) + .ok(); + socket.write_all(response.as_bytes()).ok(); + }); + (Url::parse(&format!("http://{addr}/community")).unwrap(), rx) + } + + #[tokio::test] + async fn native_discovery_policy_and_network_boundaries() { + for (body, expected) in [ + (r#"{}"#, Some(false)), + ( + r#"{"requires_nip_fi":false,"limitation":{"federated_identity":true}}"#, + Some(false), + ), + ( + r#"{"requires_nip_fi":true,"federated_identity":{"core":"unknown"}}"#, + Some(true), + ), + (r#"{"requires_nip_fi":"true"}"#, None), + (r#"{"requires_nip_fi":null}"#, None), + ("[]", None), + ("invalid", None), + ] { + let (url, request) = serve("200 OK", "", body); + let actual = fetch_policy(url.clone(), DEADLINE).await; + assert_eq!(actual.ok(), expected, "{body}"); + let request = request.recv().unwrap().to_lowercase(); + assert!(request.starts_with("get /community http/1.1")); + assert!(request.contains(&format!("host: {}", url.authority()))); + assert!(request.contains("accept: application/nostr+json")); + assert!(!request.contains("authorization:")); + assert!(!request.contains("cookie:")); + } + for status in [ + "404 Not Found", + "503 Unavailable", + "401 Unauthorized", + "302 Found", + ] { + let (url, _) = serve( + status, + "Location: http://127.0.0.1:1/other\r\n", + r#"{"requires_nip_fi":true}"#, + ); + let error = fetch_policy(url, DEADLINE).await.unwrap_err(); + // A followed redirect would instead fail transport at port 1. + assert!(error.contains(status.split(' ').next().unwrap()), "{error}"); + } + let oversized = format!( + "{{\"requires_nip_fi\":true,\"padding\":\"{}\"}}", + "x".repeat(MAX_BYTES) + ); + for headers in [ + String::new(), + format!("Content-Length: {}\r\n", oversized.len()), + ] { + let (url, _) = serve("200 OK", &headers, &oversized); + assert!(fetch_policy(url, DEADLINE) + .await + .unwrap_err() + .contains("too large")); + } + // Both an unreachable endpoint and a body that never completes are errors. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + drop(listener); + assert!(fetch_policy(url, DEADLINE).await.is_err()); + for partial in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let (release, hold) = mpsc::channel::<()>(); + std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 4096]; + let size = socket.read(&mut request).unwrap(); + assert!(size > 0); + if partial { + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 99\r\n\r\n{") + .ok(); + } + let _ = hold.recv_timeout(Duration::from_secs(10)); + }); + assert!(fetch_policy(url, Duration::from_millis(30)) + .await + .unwrap_err() + .contains("timed out")); + drop(release); + } + } + + #[tokio::test] + async fn pinned_producer_responses_use_the_native_reader() { + for (body, required) in [ + ( + include_str!("../../tests/fixtures/nip_fi/buzz_511_d3ae4935b_required.json"), + true, + ), + ( + include_str!("../../tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional.json"), + false, + ), + ( + include_str!( + "../../tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional_capable.json" + ), + false, + ), + ] { + let (url, _) = serve("200 OK", "", body); + assert_eq!(fetch_policy(url, DEADLINE).await.unwrap(), required); + } + } + + #[test] + fn arbitrary_community_urls_and_build_destination_are_scoped() { + for relay in [ + "wss://community.example", + "ws://127.0.0.1:3000/tenant", + "wss://[::1]:3443/", + ] { + assert!(discovery_url(relay).is_ok()); + } + for relay in [ + "https://community.example", + "file:///etc/passwd", + "wss://user:secret@community.example", + "wss://community.example?token=secret", + "wss://community.example/#secret", + ] { + assert!(discovery_url(relay).is_err(), "{relay}"); + } + let binding = Some("wss://community.example"); + let destination = Some("https://downloads.example/new.dmg"); + assert_eq!( + configured_upgrade_url("wss://community.example/", binding, destination, true) + .as_deref(), + destination + ); + for (relay, binding, destination, platform) in [ + ("wss://other.example", binding, destination, true), + ("ws://community.example", binding, destination, true), + ("wss://community.example:444", binding, destination, true), + ("wss://community.example/tenant", binding, destination, true), + ("wss://community.example", binding, destination, false), + ("wss://community.example", None, None, true), + ( + "wss://community.example", + binding, + Some("http://downloads.example/new"), + true, + ), + ( + "wss://community.example", + binding, + Some("https://user:secret@downloads.example/new"), + true, + ), + ] { + assert!(configured_upgrade_url(relay, binding, destination, platform).is_none()); + } + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4497eb78e56..133cd2a9060 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -848,6 +848,7 @@ pub fn run() { validate_repos_dir, get_active_workspace, fetch_workspace_icon, + fetch_relay_entry_requirements, fetch_join_policy, set_prevent_sleep_active, get_agent_memory, diff --git a/desktop/src-tauri/tests/fixtures/nip_fi/README.md b/desktop/src-tauri/tests/fixtures/nip_fi/README.md new file mode 100644 index 00000000000..2f1ac57c567 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/nip_fi/README.md @@ -0,0 +1,10 @@ +These are unchanged HTTP response bodies exported through the production HTTP +router of block/buzz BUZZ-511 PR #8988 at `d3ae4935b11e207f0736833cd5220f207be359fd`. + +The authoritative full producer SHA and regeneration program are recorded in +[the original fixture provenance](https://github.com/block/buzz/blob/082149e503a899daad5711a7815ac86e71fc3a16/mobile/test/fixtures/nip_fi/README.md). +The response bodies cover required, optional, and optional with FI capability. +The exporter verified HTTP 200, root/info agreement and Cache-Control: no-store; +its synthetic policy/verifier boundaries do not establish live acceptance. + +Before readiness, re-export from the merged producer SHA and update this pin. diff --git a/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional.json b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional.json new file mode 100644 index 00000000000..f021147e665 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional.json @@ -0,0 +1 @@ +{"requires_nip_fi":false,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"due_delivery_mode":"push","max_not_before_delta":31536000}} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional_capable.json b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional_capable.json new file mode 100644 index 00000000000..4349afea6c5 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_optional_capable.json @@ -0,0 +1 @@ +{"requires_nip_fi":false,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"federated_identity":true,"due_delivery_mode":"push","max_not_before_delta":31536000},"federated_identity":{"assertion_freshness":{"class":"offline-jwt","maximum_residual_upstream_revocation_seconds":null},"core":"client-attached"}} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_required.json b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_required.json new file mode 100644 index 00000000000..9c564a99f05 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_511_d3ae4935b_required.json @@ -0,0 +1 @@ +{"requires_nip_fi":true,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"federated_identity":true,"due_delivery_mode":"push","max_not_before_delta":31536000},"federated_identity":{"assertion_freshness":{"class":"offline-jwt","maximum_residual_upstream_revocation_seconds":null},"core":"client-attached"}} \ No newline at end of file diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 581987ed610..64a7ee5ab6e 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -12,6 +12,8 @@ import { useState, } from "react"; +import { relayClient } from "@/shared/api/relayClient"; +import { RelayEntryGate } from "./RelayEntryGate"; import { router } from "@/app/router"; import { completeCommunityViewTransition, @@ -632,6 +634,11 @@ function CommunityApp({ // Tauri backend is still configured for the previous one. const communityApplied = community.isReady && community.appliedKey === communityKey; + // Covers the connecting/onboarding curtain too, before the shell mounts. + const appliedRelayKey = communityApplied ? communityKey : null; + useEffect(() => { + if (appliedRelayKey) void relayClient.preconnect().catch(() => {}); + }, [appliedRelayKey]); useLayoutEffect(() => { if (communityApplied) { completeCommunityViewTransition(); @@ -678,6 +685,18 @@ function CommunityApp({ return ( <> {appContent} + {activeCommunity ? ( + { + communityOnboarding.clear(); + void transitionCommunity(id); + }} + /> + ) : null} {transaction ? (
void; + onRetry: () => void; +}) { + const entry = relayClient.entryRequirements; + const state = useSyncExternalStore(entry.subscribe, entry.getSnapshot); + const [editing, setEditing] = useState(false); + const [openError, setOpenError] = useState(false); + const visible = state.relayUrl === relayUrl && state.verdict !== null; + const required = state.verdict === "required"; + return ( + <> + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + data-testid="relay-entry-guidance" + > + + {required + ? "Upgrade Buzz to join this community" + : "Could not check this community"} + + + {required + ? "This community requires sign-in supported by a newer Buzz app. Your keys and saved communities are still here." + : "The community's sign-in requirements could not be checked. Try again when the community is reachable."} + +

{relayUrl}

+ {required && !state.upgradeUrl ? ( +

+ Ask your community administrator for a Buzz installer that + supports this community and your computer. +

+ ) : null} + {required && state.upgradeUrl ? ( + + ) : null} + {openError ? ( +

+ Could not open the download. Try again or ask your community + administrator. +

+ ) : null} + + {communities + .filter((community) => community.relayUrl !== relayUrl) + .map((community) => ( + + ))} + +
+
+ {editing ? ( + setEditing(false)} + onUpdated={() => setEditing(false)} + /> + ) : null} + + ); +} diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index fcdc29fc5fd..5bcb31a5cd3 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { startBootWarm } from "@/features/agents/acpRuntimesQuery"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { useForegroundQueryRefresh } from "@/features/workflows/hooks"; -import { relayClient } from "@/shared/api/relayClient"; import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers"; type AppShellLifecycleEffectsOptions = { @@ -58,18 +57,6 @@ export function useAppShellLifecycleEffects({ }; }, []); - React.useEffect(() => { - let isCancelled = false; - void relayClient.preconnect().catch((error) => { - if (!isCancelled) { - console.error("Failed to preconnect to relay", error); - } - }); - return () => { - isCancelled = true; - }; - }, []); - React.useEffect(() => { if (!desktopBadgeEnabled) { return; diff --git a/desktop/src/shared/api/relayClientResumeCursor.test.mjs b/desktop/src/shared/api/relayClientResumeCursor.test.mjs index f9df922b482..2c055fdece9 100644 --- a/desktop/src/shared/api/relayClientResumeCursor.test.mjs +++ b/desktop/src/shared/api/relayClientResumeCursor.test.mjs @@ -37,6 +37,8 @@ globalThis.window = { __TAURI_INTERNALS__: { transformCallback: () => nextCallbackId++, async invoke(command, args) { + if (command === "fetch_relay_entry_requirements") + return { required: false, upgradeUrl: null }; if (command === "plugin:websocket|connect") { dials.push(now); return 100 + dials.length; diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index eac2465a8cb..d7384c77444 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -78,8 +78,10 @@ import { } from "@/shared/api/relayAuthPolicy"; import { createRelayInboundBuffer } from "@/shared/api/relayInboundBuffer"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; +import { RelayEntryRequirements } from "./relayEntryRequirements"; type UserStatusInput = { text: string; emoji: string; expiresAt?: number }; export class RelayClient { + readonly entryRequirements = new RelayEntryRequirements(); private wsId: number | null = null; private relayUrl: string | null = null; private connectPromise: Promise | null = null; @@ -119,6 +121,7 @@ export class RelayClient { this.visibleChannelId = id; } disconnect() { + this.entryRequirements.reset(); const error = new Error("Relay disconnected for community switch."); if (this.reconnectTimeout) { @@ -561,18 +564,12 @@ export class RelayClient { inbound.receive(delivery), ); try { - if (!this.relayUrl) { - this.relayUrl = await getRelayWsUrl(); - } - const wsId = await invoke("plugin:websocket|connect", { - url: this.relayUrl, - onMessage: this.onMessageChannel, - config: {}, - }); - if (generation !== this.connectionGeneration) { - void closeWebSocket(wsId, "stale connection attempt"); - throw new Error("Relay connection attempt was superseded."); - } + const { relayUrl, wsId } = await this.entryRequirements.connect( + this.relayUrl, + this.onMessageChannel, + () => generation === this.connectionGeneration, + ); + this.relayUrl = relayUrl; this.wsId = wsId; const authentication = armRelayAuthentication( @@ -607,7 +604,9 @@ export class RelayClient { "Failed to connect to relay.", ); if (generation === this.connectionGeneration) { - this.resetConnection(connectionError); + this.resetConnection(connectionError, { + reconnect: !RelayEntryRequirements.isUpgradeRequired(error), + }); } throw connectionError; } diff --git a/desktop/src/shared/api/relayEntryRequirements.test.mjs b/desktop/src/shared/api/relayEntryRequirements.test.mjs new file mode 100644 index 00000000000..59dca619ddf --- /dev/null +++ b/desktop/src/shared/api/relayEntryRequirements.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +let relay = "wss://a.example/"; +let policy = { required: true, upgradeUrl: null }; +let dials = 0; +let pending; +const calls = []; +globalThis.window = { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + __TAURI_INTERNALS__: { + transformCallback: () => 1, + invoke: async (command, args) => { + calls.push([command, args]); + if (command === "get_relay_ws_url") return relay; + if (command === "fetch_relay_entry_requirements") { + if (pending) + return new Promise((resolve) => { + pending.resolve = resolve; + }); + if (policy instanceof Error) throw policy; + return policy; + } + if (command === "plugin:websocket|connect") { + dials++; + throw new Error("test reached socket boundary"); + } + if (command === "plugin:websocket|disconnect") return; + assert.fail(`unexpected command ${command}`); + }, + }, +}; +const { RelayClient } = await import("./relayClientSession.ts"); + +test("join/startup/reconnect share policy, retry and community-switch isolation", async () => { + const client = new RelayClient(); + try { + await assert.rejects(client.preconnect(), /newer Buzz/); + assert.equal(client.entryRequirements.getSnapshot().verdict, "required"); + assert.equal(client.terminal, true); + assert.equal(dials, 0); + // Existing subscriptions/query callers cannot bypass the required verdict. + await assert.rejects(client.fetchFirstEvent({ kinds: [0] }), /terminal/); + assert.equal(dials, 0); + // A failed retry is retryable, never a free or an upgrade verdict. + policy = new Error("HTTP 503"); + await assert.rejects(client.preconnect(), /503/); + assert.equal(client.entryRequirements.getSnapshot().verdict, "error"); + assert.equal(client.terminal, false); + // Required -> false and absent (mapped by native) resume the existing transport. + policy = { required: false, upgradeUrl: null }; + await assert.rejects(client.preconnect(), /socket boundary/); + assert.equal(client.entryRequirements.getSnapshot().verdict, null); + assert.equal(dials, 1); + client.disconnect(); + // A delayed response from A cannot change B's policy or dial its socket. + pending = {}; + const stale = client.preconnect(); + while (!pending.resolve) await new Promise(setImmediate); + const resolveA = pending.resolve; + pending = undefined; + client.disconnect(); + relay = "wss://b.example/"; + policy = { required: true, upgradeUrl: "https://downloads.example/b" }; + await assert.rejects(client.preconnect(), /newer Buzz/); + resolveA({ required: false, upgradeUrl: null }); + await assert.rejects(stale, /superseded/); + assert.equal(client.entryRequirements.getSnapshot().relayUrl, relay); + assert.equal(client.entryRequirements.getSnapshot().verdict, "required"); + assert.equal(dials, 1); + assert.ok( + calls.every( + ([command]) => !/save|delete|reset_identity|remove/.test(command), + ), + "entry check does not mutate keys or saved communities", + ); + } finally { + client.disconnect(); + } +}); diff --git a/desktop/src/shared/api/relayEntryRequirements.ts b/desktop/src/shared/api/relayEntryRequirements.ts new file mode 100644 index 00000000000..594922669f3 --- /dev/null +++ b/desktop/src/shared/api/relayEntryRequirements.ts @@ -0,0 +1,101 @@ +import { type Channel, invoke } from "@tauri-apps/api/core"; +import { getRelayWsUrl, invokeTauri } from "@/shared/api/tauri"; +import { closeWebSocket } from "./relayWebSocketClose"; + +export type EntrySnapshot = { + relayUrl: string; + verdict: "required" | "error" | null; + pending: boolean; + upgradeUrl: string | null; +}; +const empty: EntrySnapshot = { + relayUrl: "", + verdict: null, + pending: false, + upgradeUrl: null, +}; +class UpgradeRequiredError extends Error {} + +/** Owned by the relay session: never persisted, invalidated on community switch. */ +export class RelayEntryRequirements { + static isUpgradeRequired(error: unknown) { + return error instanceof UpgradeRequiredError; + } + private epoch = 0; + private snapshot = empty; + private listeners = new Set<() => void>(); + getSnapshot = () => this.snapshot; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + private set(snapshot: EntrySnapshot) { + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } + reset() { + this.epoch++; + this.set(empty); + } + async connect( + relayUrl: string | null, + onMessage: Channel, + isCurrent: () => boolean, + ) { + const resolved = relayUrl ?? (await getRelayWsUrl()); + if (!isCurrent()) + throw new Error("Relay connection attempt was superseded."); + await this.check(resolved); + if (!isCurrent()) + throw new Error("Relay connection attempt was superseded."); + const wsId = await invoke("plugin:websocket|connect", { + url: resolved, + onMessage, + config: {}, + }); + if (!isCurrent()) { + void closeWebSocket(wsId, "stale connection attempt"); + throw new Error("Relay connection attempt was superseded."); + } + return { relayUrl: resolved, wsId }; + } + async check(relayUrl: string) { + const epoch = ++this.epoch; + this.set({ + ...(this.snapshot.relayUrl === relayUrl ? this.snapshot : empty), + relayUrl, + pending: true, + }); + let result: { required: boolean; upgradeUrl: string | null }; + try { + result = await invokeTauri("fetch_relay_entry_requirements", { + relayUrl, + }); + if (!result || typeof result.required !== "boolean") + throw new Error("Invalid community discovery response"); + } catch (error) { + if (epoch === this.epoch) + this.set({ + relayUrl, + verdict: "error", + pending: false, + upgradeUrl: null, + }); + throw error; + } + if (epoch !== this.epoch) + throw new Error("Community discovery was superseded"); + this.set({ + relayUrl, + verdict: result.required ? "required" : null, + pending: false, + upgradeUrl: result.upgradeUrl, + }); + if (result.required) + throw new UpgradeRequiredError( + "This community requires a newer Buzz app", + ); + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9fa7b42b2a2..b683871d32e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -367,6 +367,12 @@ type E2eConfig = { channelMembersReadDelayMs?: number; createManagedAgentDelayMs?: number; channelTemplates?: ChannelTemplate[]; + relayEntryPolicy?: { + requiredRelay?: string; + required: boolean; + upgradeUrl?: string; + error?: string; + }; channelsReadError?: string; /** Reject successive mock `get_channels` calls, then resume. */ channelsReadErrors?: (string | null)[]; @@ -13592,8 +13598,33 @@ export function maybeInstallE2eTauriMocks() { path: "/tmp/buzz/REPOS/buzz", cloned: false, }; - case "get_relay_ws_url": + case "fetch_relay_entry_requirements": { + const configured = config?.mock?.relayEntryPolicy; + const policy = + configured?.requiredRelay && + new URL(configured.requiredRelay).href !== + new URL((payload as { relayUrl: string }).relayUrl).href + ? undefined + : configured; + if (policy?.error) throw new Error(policy.error); + return { + required: policy?.required ?? false, + upgradeUrl: policy?.upgradeUrl ?? null, + }; + } + case "get_relay_ws_url": { + if (activeConfig?.mock?.relayEntryPolicy) { + const communities = JSON.parse( + localStorage.getItem("buzz-communities") ?? "[]", + ) as Array<{ id: string; relayUrl: string }>; + const active = communities.find( + (community) => + community.id === localStorage.getItem("buzz-active-community-id"), + ); + if (active) return active.relayUrl; + } return getRelayWsUrl(activeConfig); + } case "get_default_relay_url": return getRelayWsUrl(activeConfig); case "auto_connect_default_relay_enabled": diff --git a/desktop/tests/e2e/relay-entry-guidance.spec.ts b/desktop/tests/e2e/relay-entry-guidance.spec.ts new file mode 100644 index 00000000000..bd0d61d0945 --- /dev/null +++ b/desktop/tests/e2e/relay-entry-guidance.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; +type PolicyWindow = Window & { + __BUZZ_E2E__: { + mock: { + relayEntryPolicy: { + required: boolean; + requiredRelay?: string; + upgradeUrl?: string; + error?: string; + }; + }; + }; +}; + +test("saved desktop community: required, retryable failure, recovery and keys survive", async ({ + page, +}) => { + await installMockBridge(page, { relayEntryPolicy: { required: true } }); + await page.goto("/"); + const card = page.getByTestId("relay-entry-guidance"); + await expect( + card.getByText("Upgrade Buzz to join this community"), + ).toBeVisible(); + await expect( + card.getByRole("button", { name: "Download newer Buzz" }), + ).toHaveCount(0); + await card.screenshot({ path: "test-results/relay-entry-required.png" }); + const storage = await page.evaluate(() => ({ ...localStorage })); + // Native networking is the external boundary. The session and rendered gate stay real. + await page.evaluate(() => { + const config = (window as PolicyWindow).__BUZZ_E2E__; + config.mock.relayEntryPolicy = { required: false, error: "HTTP 503" }; + }); + await card.getByRole("button", { name: "Retry community check" }).click(); + await expect(card.getByText("Could not check this community")).toBeVisible(); + await expect( + card.getByRole("button", { name: "Download newer Buzz" }), + ).toHaveCount(0); + await page.evaluate(() => { + (window as PolicyWindow).__BUZZ_E2E__.mock.relayEntryPolicy = { + required: true, + upgradeUrl: "https://downloads.example/new.dmg", + }; + }); + await card.getByRole("button", { name: "Retry community check" }).click(); + await expect( + card.getByRole("button", { name: "Download newer Buzz" }), + ).toBeVisible(); + await card.getByRole("button", { name: "Change community address" }).click(); + await expect(card).toBeHidden(); + // Returning from address editing keeps the required verdict. + await page.keyboard.press("Escape"); + await expect( + card.getByText("Upgrade Buzz to join this community"), + ).toBeVisible(); + await page.evaluate(() => { + (window as PolicyWindow).__BUZZ_E2E__.mock.relayEntryPolicy = { + required: false, + }; + }); + await card.getByRole("button", { name: "Retry community check" }).click(); + await expect(card).toBeHidden(); + const after = await page.evaluate(() => ({ ...localStorage })); + for (const [key, value] of Object.entries(storage)) { + if (/communities|identity|private.key|nsec/i.test(key)) + expect(after[key], key).toBe(value); + } +}); + +test("required community can switch to a saved free community and join another required host", async ({ + page, +}) => { + await installMockBridge( + page, + { + relayEntryPolicy: { + required: true, + requiredRelay: "wss://required.example/", + }, + }, + { skipCommunitySeed: true }, + ); + await page.addInitScript(() => { + localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "required", + name: "Required", + relayUrl: "wss://required.example/", + addedAt: new Date().toISOString(), + }, + { + id: "free", + name: "Free", + relayUrl: "ws://localhost:3000", + addedAt: new Date().toISOString(), + }, + ]), + ); + localStorage.setItem("buzz-active-community-id", "required"); + }); + await page.goto("/"); + const card = page.getByTestId("relay-entry-guidance"); + await expect( + card.getByText("Upgrade Buzz to join this community"), + ).toBeVisible(); + await card.getByRole("button", { name: "Open Free", exact: true }).click(); + await expect(card).toBeHidden(); + const activeRail = page + .getByRole("navigation", { name: "Communities" }) + .filter({ + has: page.locator( + '[data-testid="community-rail-button-free"][aria-current="true"]', + ), + }); + await expect(activeRail).toBeVisible(); + expect( + await page.evaluate( + () => JSON.parse(localStorage.getItem("buzz-communities") ?? "[]").length, + ), + ).toBe(2); + // A new join must reach the same gate while the onboarding curtain is active. + await page.evaluate(() => { + (window as PolicyWindow).__BUZZ_E2E__.mock.relayEntryPolicy = { + required: true, + requiredRelay: "wss://new-required.example/", + }; + }); + await activeRail + .getByRole("button", { name: "Add community", exact: true }) + .click(); + await page.getByTestId("add-community-join").click(); + await page + .getByLabel("Community URL or invite link") + .fill("https://new-required.example"); + await page + .getByRole("button", { name: "Join community", exact: true }) + .click(); + await expect( + card.getByText("Upgrade Buzz to join this community"), + ).toBeVisible(); + await expect(card).toContainText("new-required.example"); + expect( + await page.evaluate( + () => JSON.parse(localStorage.getItem("buzz-communities") ?? "[]").length, + ), + ).toBe(3); + await card.getByRole("button", { name: "Open Free", exact: true }).click(); + await expect(card).toBeHidden(); + expect( + await page.evaluate( + () => JSON.parse(localStorage.getItem("buzz-communities") ?? "[]").length, + ), + ).toBe(3); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 3eaf58a5e4b..87d26640361 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -275,6 +275,12 @@ type MockBridgeOptions = { /** Sequenced add-member failures. A string fails that call; null succeeds. */ addChannelMembersErrors?: (string | null)[]; channelMembersReadDelayMs?: number; + relayEntryPolicy?: { + requiredRelay?: string; + required: boolean; + upgradeUrl?: string; + error?: string; + }; channelsReadError?: string; /** Reject successive mock `get_channels` calls, then resume. */ channelsReadErrors?: (string | null)[]; From 2700a993f6c9d45d22ae602cecfbf23339633e11 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 19:55:31 -0500 Subject: [PATCH 02/13] BUZZ-517: Preserve connections when discovery fails Keep failed discovery unknown and let normal relay authentication enforce admission. Open the modal only for explicit required policy and cover authenticated reconnect, offline recovery and stale-host results. Co-authored-by: Honor Signed-off-by: Brad Seiler --- desktop/README.md | 5 +- desktop/src/app/App.tsx | 5 +- desktop/src/app/RelayEntryGate.tsx | 18 +-- .../api/relayEntryRequirements.test.mjs | 114 ++++++++++++++++-- .../src/shared/api/relayEntryRequirements.ts | 29 ++++- .../tests/e2e/relay-entry-guidance.spec.ts | 26 +++- 6 files changed, 163 insertions(+), 34 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 57ec4098bb3..e7cdaebc9d6 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -28,8 +28,9 @@ Desktop chat shell with: The legacy desktop reads public NIP-11 metadata before each WebSocket connection. Only `requires_nip_fi: true` shows the upgrade card. Missing/false requirements -keep the existing connection flow; discovery failures show a retryable check -failure. Switching communities invalidates pending checks. The card does not +keep the existing connection flow. Discovery errors remain unknown policy and +continue the existing socket/authentication path, where the relay enforces +admission. Only explicit required policy opens the upgrade modal. Switching communities invalidates pending checks. The card does not reset keys or remove saved communities. Packagers may set both `BUZZ_BUILD_REQUIRED_AUTH_UPGRADE_RELAY` (an exact `ws` or diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 64a7ee5ab6e..3a73d9f18c8 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -637,7 +637,10 @@ function CommunityApp({ // Covers the connecting/onboarding curtain too, before the shell mounts. const appliedRelayKey = communityApplied ? communityKey : null; useEffect(() => { - if (appliedRelayKey) void relayClient.preconnect().catch(() => {}); + if (appliedRelayKey) + void relayClient.preconnect().catch((error) => { + console.error("Failed to preconnect to relay", error); + }); }, [appliedRelayKey]); useLayoutEffect(() => { if (communityApplied) { diff --git a/desktop/src/app/RelayEntryGate.tsx b/desktop/src/app/RelayEntryGate.tsx index cbd2692474b..aefbb114c38 100644 --- a/desktop/src/app/RelayEntryGate.tsx +++ b/desktop/src/app/RelayEntryGate.tsx @@ -25,8 +25,7 @@ export function RelayEntryGate({ const state = useSyncExternalStore(entry.subscribe, entry.getSnapshot); const [editing, setEditing] = useState(false); const [openError, setOpenError] = useState(false); - const visible = state.relayUrl === relayUrl && state.verdict !== null; - const required = state.verdict === "required"; + const visible = state.relayUrl === relayUrl && state.verdict === "required"; return ( <> @@ -36,24 +35,19 @@ export function RelayEntryGate({ onPointerDownOutside={(event) => event.preventDefault()} data-testid="relay-entry-guidance" > - - {required - ? "Upgrade Buzz to join this community" - : "Could not check this community"} - + Upgrade Buzz to join this community - {required - ? "This community requires sign-in supported by a newer Buzz app. Your keys and saved communities are still here." - : "The community's sign-in requirements could not be checked. Try again when the community is reachable."} + This community requires sign-in supported by a newer Buzz app. Your + keys and saved communities are still here.

{relayUrl}

- {required && !state.upgradeUrl ? ( + {!state.upgradeUrl ? (

Ask your community administrator for a Buzz installer that supports this community and your computer.

) : null} - {required && state.upgradeUrl ? ( + {state.upgradeUrl ? ( - ) : null} +

+ Download the latest Buzz client at builderlab.xyz to continue. +

+ {openError ? (

- Could not open the download. Try again or ask your community - administrator. + Could not open the download page. Open https://builderlab.xyz in + your browser to download the latest Buzz client.

) : null} {openError ? (

- Could not open the download page. Open https://builderlab.xyz in - your browser to download the latest Buzz client. + Could not open the download page. Open https://buzz.xyz in your + browser to download the latest Buzz client.

) : null}