diff --git a/desktop/README.md b/desktop/README.md index 79921e4d299..21570a05087 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -29,3 +29,21 @@ 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 retain an unknown-policy +state and continue the existing socket/authentication path; the relay enforces +admission. Query/fragment URLs keep their existing socket behavior; the native +discovery reader rejects them before making an HTTP request. Credentials never +reach a fallback dial. Only explicit required policy opens the upgrade modal. Switching +communities invalidates pending checks. The card does not +reset keys or remove saved communities. + +The upgrade card directs public users to the fixed `https://buzz.xyz` URL to +download an up-to-date Buzz client on every supported desktop platform and for +every required community. +If the browser cannot open, the card shows the public URL for manual navigation. +Discovery never supplies a trusted download or sign-in origin. 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/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..db20a4d34fa --- /dev/null +++ b/desktop/src-tauri/src/commands/relay_entry_requirements.rs @@ -0,0 +1,249 @@ +//! 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, +} + +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) +} + +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 }) +} + +#[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), + ("Proxy sign-in", 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_main_6b8a72e835_required.json"), + true, + ), + ( + include_str!("../../tests/fixtures/nip_fi/buzz_main_6b8a72e835_optional.json"), + false, + ), + ( + include_str!( + "../../tests/fixtures/nip_fi/buzz_main_6b8a72e835_optional_capable.json" + ), + false, + ), + ] { + let (url, _) = serve("200 OK", "", body); + assert_eq!(fetch_policy(url, DEADLINE).await.unwrap(), required); + } + } + + #[test] + fn arbitrary_community_urls_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}"); + } + } +} 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..e5039c277fe --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/nip_fi/README.md @@ -0,0 +1,68 @@ +# Community authentication discovery fixtures + +These complete HTTP response bodies come from the production router at +[main commit 6b8a72e8355125d24e4f4fab1e083dced4119f5c](https://github.com/block/buzz/commit/6b8a72e8355125d24e4f4fab1e083dced4119f5c). +The [NIP-FI discovery contract](https://github.com/block/buzz/blob/6b8a72e8355125d24e4f4fab1e083dced4119f5c/docs/nips/NIP-FI.md#discovery) +defines the top-level `requires_nip_fi` boolean. + +| Fixture suffix | Global mode | Synthetic Host | Required | FI capability | +| --- | --- | --- | --- | --- | +| `required` | shadow | `required.example` | true | true | +| `optional` | off | `free.example` | false | false | +| `optional_capable` | shadow | `free.example` | false | true | + +The required fixture uses Shadow mode: Off never requires federated identity, +even when the stored community policy requires it. Capability alone does not +make an optional host required. + +The exporter calls `router::build_router` through `tower::ServiceExt::oneshot` +with synthetic policy and verifier boundaries. It verifies HTTP 200, +`Cache-Control: no-store`, and matching decoded documents from `/` and `/info`, +then saves the unmodified `/info` body. These fixtures verify wire compatibility; +they do not establish deployed-host or installed-app acceptance. + +To reproduce, use a detached checkout of the pinned source commit and append +this temporary exporter to `crates/buzz-relay/src/nip_fi_policy/tests.rs`: + +```rust +#[tokio::test] +async fn export_buzz_517_discovery_fixtures() { + let out = std::path::PathBuf::from(std::env::var("BUZZ_FIXTURE_OUT").unwrap()); + std::fs::create_dir_all(&out).unwrap(); + for (name, mode, host) in [ + ("required", NipFiMode::Shadow, "required.example"), + ("optional", NipFiMode::Off, "free.example"), + ("optional_capable", NipFiMode::Shadow, "free.example"), + ] { + let (state, _, _, _) = fixture(mode).await; + let app = crate::router::build_router(state); + let mut documents = Vec::new(); + for path in ["/", "/info"] { + let response = app.clone().oneshot( + Request::get(path) + .header(header::HOST, host) + .header(header::ACCEPT, "application/nostr+json") + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let bytes = to_bytes(response.into_body(), 256 * 1024).await.unwrap(); + documents.push(serde_json::from_slice::(&bytes).unwrap()); + if path == "/info" { + std::fs::write(out.join(format!("{name}.json")), &bytes).unwrap(); + } + } + assert_eq!(documents[0], documents[1]); + } +} +``` + +Run from that checkout with an absolute output directory: + +```sh +BUZZ_FIXTURE_OUT=/tmp/buzz-517-discovery cargo test --locked -p buzz-relay --lib nip_fi_policy::tests::export_buzz_517_discovery_fixtures -- --exact +``` + +The files here retain those response bytes with the `buzz_main_6b8a72e835_` +filename prefix. Legacy relays without the field are covered by the native +reader's absent-field cases. diff --git a/desktop/src-tauri/tests/fixtures/nip_fi/buzz_main_6b8a72e835_optional.json b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_main_6b8a72e835_optional.json new file mode 100644 index 00000000000..0dab8230c33 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_main_6b8a72e835_optional.json @@ -0,0 +1 @@ +{"requires_nip_fi":false,"name":"Buzz Relay","description":"Buzz — private team communication relay","read_state_snapshot":{"community_id":"00000000-0000-0000-0000-000000000000","max_event_array_bytes":8388608,"max_events":4096,"version":1},"artifacts":{"count":"/count","incremental":{"checkpointLifetimeSeconds":2592000,"currentRows":100,"overlapSeconds":120,"pageRows":100,"permanentChannelsOnly":true,"responseBytes":8388608,"version":1},"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_main_6b8a72e835_optional_capable.json b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_main_6b8a72e835_optional_capable.json new file mode 100644 index 00000000000..89e6634f9d6 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_main_6b8a72e835_optional_capable.json @@ -0,0 +1 @@ +{"requires_nip_fi":false,"name":"Buzz Relay","description":"Buzz — private team communication relay","read_state_snapshot":{"community_id":"00000000-0000-0000-0000-000000000000","max_event_array_bytes":8388608,"max_events":4096,"version":1},"artifacts":{"count":"/count","incremental":{"checkpointLifetimeSeconds":2592000,"currentRows":100,"overlapSeconds":120,"pageRows":100,"permanentChannelsOnly":true,"responseBytes":8388608,"version":1},"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_main_6b8a72e835_required.json b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_main_6b8a72e835_required.json new file mode 100644 index 00000000000..2124fc0a615 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/nip_fi/buzz_main_6b8a72e835_required.json @@ -0,0 +1 @@ +{"requires_nip_fi":true,"name":"Buzz Relay","description":"Buzz — private team communication relay","read_state_snapshot":{"community_id":"00000000-0000-0000-0000-000000000000","max_event_array_bytes":8388608,"max_events":4096,"version":1},"artifacts":{"count":"/count","incremental":{"checkpointLifetimeSeconds":2592000,"currentRows":100,"overlapSeconds":120,"pageRows":100,"permanentChannelsOnly":true,"responseBytes":8388608,"version":1},"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..254d6d0e693 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, @@ -390,6 +392,14 @@ function CommunityApp({ // an atomic check of both ID and stage before mutating state. const transactionRef = useRef(communityOnboarding.transaction); transactionRef.current = communityOnboarding.transaction; + const cancelEntryOnboarding = useCallback(() => { + // Address edits and switches abandon this relay's join. Fence pending + // profile results immediately, before React commits the cleared state. + transactionRef.current = null; + connectingTransactionRef.current = null; + profileCheckTransactionRef.current = null; + communityOnboarding.clear(); + }, [communityOnboarding]); const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false); const [resumeFirstCommunityPage, setResumeFirstCommunityPage] = useState(null); @@ -632,6 +642,14 @@ 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((error) => { + console.error("Failed to preconnect to relay", error); + }); + }, [appliedRelayKey]); useLayoutEffect(() => { if (communityApplied) { completeCommunityViewTransition(); @@ -678,6 +696,19 @@ function CommunityApp({ return ( <> {appContent} + {activeCommunity ? ( + { + cancelEntryOnboarding(); + void transitionCommunity(id); + }} + /> + ) : null} {transaction ? (
void; + onRetry: () => void; + onAddressChanged: () => 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 === "required"; + return ( + <> + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + data-testid="relay-entry-guidance" + > + Upgrade Buzz to join this community + + This community requires sign-in supported by a newer Buzz app. Your + keys and saved communities are still here. + +

{relayUrl}

+

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

+ + {openError ? ( +

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

+ ) : null} + + {communities + .filter((community) => community.relayUrl !== relayUrl) + .map((community) => ( + + ))} + +
+
+ {editing ? ( + setEditing(false)} + onUpdated={(_name, updatedRelayUrl) => { + if (updatedRelayUrl !== relayUrl) onAddressChanged(); + 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..5cd2f2bd63f 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 }; 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..a690e80cfda --- /dev/null +++ b/desktop/src/shared/api/relayEntryRequirements.test.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +let relay = "wss://a.example/"; +let policy = { required: true }; +let dials = 0; +let auths = 0; +let pending; +let currentClient; +let offline = false; +let authAccepted = true; +const calls = []; +const deliver = (client, frame) => + client.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + client.connectionGeneration, + ); +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++; + if (offline) throw new Error("network offline"); + const client = currentClient; + setImmediate(() => { + void deliver(client, ["AUTH", "test-challenge"]); + }); + return dials; + } + if (command === "create_auth_event") { + return JSON.stringify({ + id: `auth-${dials}`, + kind: 22242, + tags: [ + ["challenge", args.challenge], + ["relay", args.relayUrl], + ], + content: "", + }); + } + if (command === "plugin:websocket|send") { + const frame = JSON.parse(args.message.data); + if (frame[0] === "AUTH") { + auths++; + const client = currentClient; + setImmediate(() => { + void deliver(client, [ + "OK", + frame[1].id, + authAccepted, + authAccepted ? "" : "restricted: not a relay member", + ]); + }); + } + return; + } + if (command === "plugin:websocket|disconnect") return; + assert.fail(`unexpected command ${command}`); + }, + }, +}; +const { RelayClient } = await import("./relayClientSession.ts"); +const drop = (client) => + client.handleWsMessage( + { type: "Close", data: { code: 1006, reason: "network changed" } }, + client.connectionGeneration, + ); + +test("failed discovery preserves authenticated legacy reconnect and required-host isolation", async () => { + const client = new RelayClient(); + currentClient = client; + try { + await assert.rejects(client.preconnect(), /newer Buzz/); + assert.equal(client.entryRequirements.getSnapshot().verdict, "required"); + assert.equal(client.terminal, true); + assert.equal(dials, 0); + await assert.rejects(client.fetchFirstEvent({ kinds: [0] }), /terminal/); + assert.equal(dials, 0); + // Only native socket, HTTP and signing boundaries are fake. Authentication, + // connection state, reset, reconnect pacing and entry decisions stay real. + for (const failure of [ + "HTTP 503", + "Community discovery timed out", + "Invalid community discovery document (HTML)", + "Invalid community authentication requirement", + ]) { + policy = new Error(failure); + await client.preconnect(); + assert.equal(client.getConnectionState(), "connected", failure); + assert.equal( + client.entryRequirements.getSnapshot().verdict, + "error", + "failure is unknown policy, never optional", + ); + assert.equal(client.terminal, false); + assert.equal( + auths, + dials, + "successful native dial completed real AUTH/OK session path", + ); + await drop(client); + } + // Offline retry remains an ordinary transport failure, and resumes on recovery. + policy = new Error("Could not reach community discovery"); + offline = true; + await assert.rejects(client.resumeReconnect(), /network offline/); + assert.equal(client.entryRequirements.getSnapshot().verdict, "error"); + assert.equal(client.terminal, false); + offline = false; + await client.resumeReconnect(); + assert.equal(client.getConnectionState(), "connected"); + await drop(client); + policy = { required: false }; + await client.resumeReconnect(); + assert.equal(client.entryRequirements.getSnapshot().verdict, null); + client.disconnect(); + // Unknown policy does not bypass relay enforcement. + policy = new Error("HTTP 503"); + authAccepted = false; + await assert.rejects(client.preconnect(), /not a relay member/); + assert.equal(client.terminal, true); + authAccepted = true; + client.disconnect(); + // Preserve legacy socket URLs without sending query/fragment data to HTTP discovery. + for (const legacyUrl of [ + "wss://a.example/?route=free", + "wss://a.example/#view", + ]) { + relay = legacyUrl; + policy = new Error("Community discovery rejects query or fragment"); + await client.preconnect(); + assert.equal(client.getConnectionState(), "connected"); + assert.equal(client.entryRequirements.getSnapshot().verdict, "error"); + assert.equal( + calls + .filter(([command]) => command === "plugin:websocket|connect") + .at(-1)[1].url, + legacyUrl, + ); + client.disconnect(); + } + // Invalid addresses cannot use discovery fallback to send credentials. + const beforeInvalid = dials; + relay = "wss://user:secret@a.example/"; + await assert.rejects(client.preconnect(), /without credentials/); + assert.equal(dials, beforeInvalid); + client.disconnect(); + relay = "wss://a.example/"; + // A delayed result from A cannot replace B's required policy or dial A. + const beforeStale = dials; + 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 }; + await assert.rejects(client.preconnect(), /newer Buzz/); + resolveA({ required: false }); + await assert.rejects(stale, /superseded/); + assert.equal(client.entryRequirements.getSnapshot().relayUrl, relay); + assert.equal(client.entryRequirements.getSnapshot().verdict, "required"); + assert.equal(dials, beforeStale); + assert.ok( + calls.every( + ([command]) => !/save|delete|reset_identity|remove/.test(command), + ), + "entry checks do not mutate keys or 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..2969c373463 --- /dev/null +++ b/desktop/src/shared/api/relayEntryRequirements.ts @@ -0,0 +1,117 @@ +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; +}; +const empty: EntrySnapshot = { + relayUrl: "", + verdict: null, + pending: false, +}; +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()); + const address = new URL(resolved); + if ( + !["ws:", "wss:"].includes(address.protocol) || + address.username || + address.password + ) { + throw new Error("Community URL must use ws or wss without credentials"); + } + if (!isCurrent()) + throw new Error("Relay connection attempt was superseded."); + try { + await this.check(resolved); + } catch (error) { + if (RelayEntryRequirements.isUpgradeRequired(error)) throw error; + // Unknown policy is not optional. The relay still enforces admission; + // a missing public document must not break legacy/free connections. + console.warn( + "Could not check community authentication requirements; trying the relay", + error, + ); + } + 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; + }; + 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, + }); + throw error; + } + if (epoch !== this.epoch) + throw new Error("Community discovery was superseded"); + this.set({ + relayUrl, + verdict: result.required ? "required" : null, + pending: false, + }); + 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..5facb404cc6 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -367,6 +367,11 @@ type E2eConfig = { channelMembersReadDelayMs?: number; createManagedAgentDelayMs?: number; channelTemplates?: ChannelTemplate[]; + relayEntryPolicy?: { + requiredRelay?: string; + required: boolean; + error?: string; + }; channelsReadError?: string; /** Reject successive mock `get_channels` calls, then resume. */ channelsReadErrors?: (string | null)[]; @@ -13592,8 +13597,32 @@ 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, + }; + } + 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..36988e32ebf --- /dev/null +++ b/desktop/tests/e2e/relay-entry-guidance.spec.ts @@ -0,0 +1,301 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; +type PolicyWindow = Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ + command: string; + payload: { url?: string }; + }>; + __BUZZ_E2E__: { + mock: { + openerError?: string; + relayEntryPolicy: { + required: boolean; + requiredRelay?: 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 latest Buzz" }), + ).toBeVisible(); + await card.screenshot({ path: "test-results/relay-entry-required.png" }); + let 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).toBeHidden(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.waitForFunction( + () => + ( + window as Window & { + __BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string; + } + ).__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.() === "connected", + ); + const beforeReload = await page.evaluate(() => ({ ...localStorage })); + for (const [key, value] of Object.entries(storage)) { + if (/communities|identity|private.key|nsec/i.test(key)) + expect(beforeReload[key], key).toBe(value); + } + // The test bridge reseeds the launch timestamp on reload; compare retained + // storage within each launch rather than comparing the fixture's timestamps. + await page.reload(); + storage = await page.evaluate(() => ({ ...localStorage })); + await expect( + card.getByText("Upgrade Buzz to join this community"), + ).toBeVisible(); + await expect(card).toContainText( + "Download the latest Buzz client at buzz.xyz to continue.", + ); + await card.getByRole("button", { name: "Download latest Buzz" }).click(); + expect( + await page.evaluate(() => + (window as PolicyWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ + ?.filter((call) => call.command === "plugin:opener|open_url") + .map((call) => call.payload.url), + ), + ).toEqual(["https://buzz.xyz"]); + await page.evaluate(() => { + (window as PolicyWindow).__BUZZ_E2E__.mock.openerError = + "No browser available"; + }); + await card.getByRole("button", { name: "Download latest Buzz" }).click(); + await expect(card.getByRole("alert")).toContainText("https://buzz.xyz"); + 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(); + await expect(page.getByTestId("community-rail-button-free")).toHaveCount(1); + 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"); + await expect(card).toContainText( + "Download the latest Buzz client at buzz.xyz to continue.", + ); + await expect( + card.getByRole("button", { name: "Download latest Buzz" }), + ).toBeVisible(); + 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(); + await expect(page.getByTestId("community-rail-button-free")).toHaveCount(1); + expect( + await page.evaluate( + () => JSON.parse(localStorage.getItem("buzz-communities") ?? "[]").length, + ), + ).toBe(3); +}); + +test("changing a required join address cancels old onboarding and its held profile check", async ({ + page, +}) => { + await installMockBridge( + page, + { + relayEntryPolicy: { + required: true, + requiredRelay: "wss://required.example", + }, + deferProfileReads: true, + profileHasEvent: true, + }, + { skipCommunitySeed: true }, + ); + await page.addInitScript(() => { + const now = new Date().toISOString(); + localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "required", + name: "Required", + relayUrl: "wss://required.example", + addedAt: now, + }, + ]), + ); + localStorage.setItem("buzz-active-community-id", "required"); + localStorage.setItem( + "buzz-community-onboarding-transaction.v1", + JSON.stringify({ + id: "old-required-join", + source: "add-community", + stage: "connecting", + relayUrl: "wss://required.example", + communityName: "Required", + communityId: "required", + addedCommunity: true, + createdAt: now, + updatedAt: now, + }), + ); + }); + await page.routeWebSocket( + /required\.example|replacement\.example/, + (socket) => socket.close(), + ); + await page.goto("/"); + const card = page.getByTestId("relay-entry-guidance"); + await expect(card).toBeVisible(); + await page.waitForFunction( + () => + (( + window as Window & { + __BUZZ_E2E_PROFILE_READS_PENDING__?: () => number; + } + ).__BUZZ_E2E_PROFILE_READS_PENDING__?.() ?? 0) > 0, + ); + // A label change keeps the live join; changing the relay cancels it. + await card.getByRole("button", { name: "Change community address" }).click(); + const editor = page.getByTestId("community-change-overlay"); + await editor.locator("#community-edit-name").fill("Renamed"); + await editor.getByRole("button", { name: "Save changes" }).click(); + await editor.getByRole("button", { name: "Use anyway" }).click(); + await expect(card).toBeVisible(); + expect( + await page.evaluate( + () => + JSON.parse( + localStorage.getItem("buzz-community-onboarding-transaction.v1") ?? + "null", + )?.id, + ), + ).toBe("old-required-join"); + await card.getByRole("button", { name: "Change community address" }).click(); + await editor.locator("#community-edit-url").fill("wss://replacement.example"); + await editor.getByRole("button", { name: "Save changes" }).click(); + await editor.getByRole("button", { name: "Use anyway" }).click(); + await expect(card).toBeHidden(); + expect( + await page.evaluate(() => + localStorage.getItem("buzz-community-onboarding-transaction.v1"), + ), + ).toBeNull(); + await page.evaluate(() => { + ( + window as Window & { + __BUZZ_E2E_RELEASE_PROFILE_READS__?: () => number; + } + ).__BUZZ_E2E_RELEASE_PROFILE_READS__?.(); + }); + await expect( + page.getByRole("button", { name: "Search everything", exact: true }), + ).toBeVisible(); + const state = await page.evaluate(() => ({ ...localStorage })); + expect(state["buzz-communities"]).toContain("wss://replacement.example"); + expect( + Object.keys(state).filter((key) => + key.startsWith( + `buzz-community-onboarding-complete.v1:${encodeURIComponent("wss://required.example")}:`, + ), + ), + ).toEqual([]); + expect(state["buzz-community-onboarding-transaction.v1"]).toBeUndefined(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 3eaf58a5e4b..1e914736142 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -275,6 +275,11 @@ type MockBridgeOptions = { /** Sequenced add-member failures. A string fails that call; null succeeds. */ addChannelMembersErrors?: (string | null)[]; channelMembersReadDelayMs?: number; + relayEntryPolicy?: { + requiredRelay?: string; + required: boolean; + error?: string; + }; channelsReadError?: string; /** Reject successive mock `get_channels` calls, then resume. */ channelsReadErrors?: (string | null)[];