From 0019f80765e96f056e81b57789b8b5fb80936f72 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Mon, 27 Jul 2026 17:52:36 -0400 Subject: [PATCH] fix(desktop): fetch join policies through native networking (#2862) ## Context Adding an existing community by relay URL could fail with `Community rejected: Load failed` even when its WebSocket endpoint was reachable. The Add Community flow fetched `/api/join-policy` from the WebView, so a relay without a matching CORS allowance blocked the policy request before the app could join it. ## Summary This bug fix fetches join policies through Tauri's native networking layer for direct URL joins. Invite-code discovery, policy acceptance, and signed invite claims remain on the WebView path so those operations can migrate together later. ## Changes - Uses native networking for Add Community and first-community direct URL join-policy requests. - Validates relay schemes, rejects URLs containing credentials, and refuses redirects. - Bounds declared and chunked native responses before JSON parsing. - Preserves existing `404`, non-success status, malformed JSON, and absent-policy behavior. - Requires every join-policy caller to choose its transport explicitly. Public relays using Buzz's default permissive CORS configuration are not known to be affected. ### Related issue Related to #2872. ### Testing #### Reviewer-reproducible examples End-to-end red/green requires a relay with restrictive CORS and a Buzz identity authorized to join it. ##### Red: `main` From a clean checkout of `main`: ```bash . ./bin/activate-hermit just staging ``` In Buzz Desktop: 1. Add another community so the restrictive-CORS relay can be removed. 2. Remove that relay. 3. Open Add Community and enter the relay's WebSocket URL. 4. Select Add Community. Observed result: ```text Community rejected: Load failed ``` ##### Green: this PR From a clean checkout of this branch: ```bash . ./bin/activate-hermit just staging ``` Repeat the same steps above. Observed result: ```text The community rejoins successfully. ``` Supporting checks: - Six native join-policy tests, including oversized declared and chunked responses. - Four TypeScript API tests, including the native command contract. - E2E build and four focused onboarding and sidebar Playwright tests. - Full `just ci` and pre-push suites. - Builderbot, Kalvin, and minimize-diff review fanout found no actionable issues after the final rebase. --- desktop/src-tauri/src/commands/join_policy.rs | 193 ++++++++++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + .../communities/ui/CommunityEditForm.tsx | 4 +- .../onboarding/ui/InviteRedeemForm.tsx | 6 +- desktop/src/shared/api/invites.test.mjs | 37 +++- desktop/src/shared/api/invites.ts | 52 +++-- desktop/src/testing/e2eBridge.ts | 9 + desktop/tests/e2e/onboarding.spec.ts | 12 -- desktop/tests/e2e/sidebar.spec.ts | 31 +-- desktop/tests/helpers/bridge.ts | 7 + 11 files changed, 292 insertions(+), 62 deletions(-) create mode 100644 desktop/src-tauri/src/commands/join_policy.rs diff --git a/desktop/src-tauri/src/commands/join_policy.rs b/desktop/src-tauri/src/commands/join_policy.rs new file mode 100644 index 000000000..d2a357c4f --- /dev/null +++ b/desktop/src-tauri/src/commands/join_policy.rs @@ -0,0 +1,193 @@ +use futures_util::StreamExt; +use serde_json::Value; +use std::time::Duration; +use url::Url; + +// Each relay policy document is capped at 256 KiB before JSON encoding. Four +// MiB covers two maximally escaped documents plus the response envelope. +const MAX_JOIN_POLICY_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +const JOIN_POLICY_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +fn join_policy_url(relay_url: &str) -> Result { + let mut url = Url::parse(relay_url.trim()).map_err(|_| "invalid relay URL".to_string())?; + let http_scheme = match url.scheme() { + "wss" => "https", + "ws" => "http", + _ => return Err("relay URL must use ws:// or wss://".to_string()), + }; + url.set_scheme(http_scheme) + .map_err(|_| "invalid relay URL scheme".to_string())?; + + if !url.username().is_empty() || url.password().is_some() { + return Err("relay URL must not contain credentials".to_string()); + } + + let base_path = url.path().trim_end_matches('/'); + url.set_path(&format!("{base_path}/api/join-policy")); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +/// Fetch an arbitrary relay's optional join policy through native networking. +#[tauri::command] +pub async fn fetch_join_policy(relay_url: String) -> Result, String> { + let url = join_policy_url(&relay_url)?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("failed to build join policy client: {error}"))?; + let response = client + .get(url) + .timeout(JOIN_POLICY_REQUEST_TIMEOUT) + .send() + .await + .map_err(|error| format!("join policy request failed: {error}"))?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + + let body = read_join_policy_json(response).await?; + Ok(body + .get("policy") + .filter(|policy| !policy.is_null()) + .cloned()) +} + +async fn read_join_policy_json(response: reqwest::Response) -> Result { + if response + .content_length() + .is_some_and(|length| length > MAX_JOIN_POLICY_RESPONSE_BYTES as u64) + { + return Err("relay returned oversized join policy".to_string()); + } + + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("reading join policy failed: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > MAX_JOIN_POLICY_RESPONSE_BYTES { + return Err("relay returned oversized join policy".to_string()); + } + bytes.extend_from_slice(&chunk); + } + + serde_json::from_slice(&bytes).map_err(|_| "relay returned malformed join policy".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{Body, Bytes}, + http::Response, + response::Redirect, + routing::get, + Json, Router, + }; + use std::convert::Infallible; + + async fn test_relay(router: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("ws://{address}") + } + + #[test] + fn converts_relay_urls_to_join_policy_urls() { + assert_eq!( + join_policy_url("wss://relay.example.com/") + .unwrap() + .as_str(), + "https://relay.example.com/api/join-policy" + ); + assert_eq!( + join_policy_url("ws://localhost:3000/base") + .unwrap() + .as_str(), + "http://localhost:3000/base/api/join-policy" + ); + } + + #[test] + fn rejects_non_relay_schemes_and_credentials() { + assert!(join_policy_url("https://relay.example.com").is_err()); + assert!(join_policy_url("wss://user:secret@relay.example.com").is_err()); + } + + #[tokio::test] + async fn reads_an_optional_policy_without_webview_cors() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + Json(serde_json::json!({ + "policy": { + "terms_markdown": "# Terms", + "age_attestation_required": true, + "version": "v1" + } + })) + }), + )) + .await; + + let policy = fetch_join_policy(relay_url).await.unwrap().unwrap(); + assert_eq!(policy["version"], "v1"); + assert_eq!(policy["age_attestation_required"], true); + } + + #[tokio::test] + async fn refuses_join_policy_redirects() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { Redirect::temporary("http://127.0.0.1:1/private") }), + )) + .await; + + assert_eq!(fetch_join_policy(relay_url).await.unwrap_err(), "HTTP 307"); + } + + #[tokio::test] + async fn rejects_declared_oversized_join_policy() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + Response::builder() + .body(Body::from(vec![b'x'; MAX_JOIN_POLICY_RESPONSE_BYTES + 1])) + .unwrap() + }), + )) + .await; + + assert_eq!( + fetch_join_policy(relay_url).await.unwrap_err(), + "relay returned oversized join policy" + ); + } + + #[tokio::test] + async fn rejects_chunked_oversized_join_policy() { + let relay_url = test_relay(Router::new().route( + "/api/join-policy", + get(|| async { + let chunk = Bytes::from(vec![b'x'; MAX_JOIN_POLICY_RESPONSE_BYTES + 1]); + Body::from_stream(futures_util::stream::once(async move { + Ok::<_, Infallible>(chunk) + })) + }), + )) + .await; + + assert_eq!( + fetch_join_policy(relay_url).await.unwrap_err(), + "relay returned oversized join policy" + ); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a048ad24a..1c89ee4f7 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ mod export_util; mod global_agent_config; mod identity; mod identity_archive; +mod join_policy; mod legacy_storage; mod link_preview; pub(crate) mod media; @@ -78,6 +79,7 @@ pub use engrams::*; pub use global_agent_config::*; pub use identity::*; pub use identity_archive::*; +pub use join_policy::*; pub use legacy_storage::*; pub use link_preview::*; pub use media::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 64405d044..5346791cc 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -895,6 +895,7 @@ pub fn run() { validate_repos_dir, get_active_workspace, fetch_workspace_icon, + fetch_join_policy, set_prevent_sleep_active, get_agent_memory, relay_reconnect_hook, diff --git a/desktop/src/features/communities/ui/CommunityEditForm.tsx b/desktop/src/features/communities/ui/CommunityEditForm.tsx index 4c94acf10..76837b65a 100644 --- a/desktop/src/features/communities/ui/CommunityEditForm.tsx +++ b/desktop/src/features/communities/ui/CommunityEditForm.tsx @@ -68,7 +68,7 @@ export function CommunityEditForm({ let cancelled = false; const timeoutId = window.setTimeout(() => { - void getJoinPolicy(normalizedUrl) + void getJoinPolicy(normalizedUrl, "native") .then((policy) => { if (cancelled || !policy) return; setJoinPolicy(policy); @@ -105,7 +105,7 @@ export function CommunityEditForm({ if (joinPolicyRequired) { try { - const policy = await getJoinPolicy(normalizedUrl); + const policy = await getJoinPolicy(normalizedUrl, "native"); if (!policy) { onSubmit(trimmedName, normalizedUrl); return; diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index 78929cde1..fa50552ba 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -118,7 +118,7 @@ export function InviteRedeemForm({ const code = parsedInvite?.code; let cancelled = false; const timeoutId = window.setTimeout(() => { - void getJoinPolicy(relayWsUrl) + void getJoinPolicy(relayWsUrl, code ? "webview" : "native") .then((policy) => { if (cancelled || !policy) return; setJoinPolicy(policy); @@ -156,7 +156,7 @@ export function InviteRedeemForm({ setPolicyError(null); setIsLoadingPolicy(true); try { - const policy = await getJoinPolicy(normalizedRelayUrl); + const policy = await getJoinPolicy(normalizedRelayUrl, "native"); if (!policy) { onConnect?.(normalizedRelayUrl, apiToken.trim() || undefined); return; @@ -205,7 +205,7 @@ export function InviteRedeemForm({ setPolicyError(null); setIsLoadingPolicy(true); try { - const policy = await getJoinPolicy(relayWsUrl); + const policy = await getJoinPolicy(relayWsUrl, "webview"); if (!policy) { onRedeem(relayWsUrl, parsedInvite.code); return; diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs index eb4ca3dad..4c5b78d61 100644 --- a/desktop/src/shared/api/invites.test.mjs +++ b/desktop/src/shared/api/invites.test.mjs @@ -28,7 +28,7 @@ test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () = { status: 200 }, ), async () => { - assert.deepEqual(await getJoinPolicy("wss://relay.example"), { + assert.deepEqual(await getJoinPolicy("wss://relay.example", "webview"), { termsMarkdown: "# Terms", privacyMarkdown: "# Privacy", ageAttestationRequired: true, @@ -40,15 +40,44 @@ test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () = test("getJoinPolicy preserves opt-in behavior for unconfigured and older relays", async () => { await withFetch(new Response(JSON.stringify({}), { status: 200 }), async () => - assert.equal(await getJoinPolicy("wss://relay.example"), null), + assert.equal(await getJoinPolicy("wss://relay.example", "webview"), null), ); await withFetch(new Response(null, { status: 404 }), async () => - assert.equal(await getJoinPolicy("wss://relay.example"), null), + assert.equal(await getJoinPolicy("wss://relay.example", "webview"), null), ); }); test("getJoinPolicy fails closed on a policy endpoint error", async () => { await withFetch(new Response(null, { status: 503 }), async () => - assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/), + assert.rejects(getJoinPolicy("wss://relay.example", "webview"), /HTTP 503/), ); }); + +test("getJoinPolicy maps the native command response", async () => { + const previousWindow = globalThis.window; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke(command, args) { + assert.equal(command, "fetch_join_policy"); + assert.deepEqual(args, { relayUrl: "wss://relay.example" }); + return Promise.resolve({ + terms_markdown: "# Terms", + privacy_markdown: "# Privacy", + age_attestation_required: true, + version: "policy-v1", + }); + }, + }, + }; + + try { + assert.deepEqual(await getJoinPolicy("wss://relay.example", "native"), { + termsMarkdown: "# Terms", + privacyMarkdown: "# Privacy", + ageAttestationRequired: true, + version: "policy-v1", + }); + } finally { + globalThis.window = previousWindow; + } +}); diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts index ae03c71ad..e3d60cfb1 100644 --- a/desktop/src/shared/api/invites.ts +++ b/desktop/src/shared/api/invites.ts @@ -1,5 +1,9 @@ import { relayHttpFromWs } from "@/shared/api/inviteHelpers"; -import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri"; +import { + getRelayHttpUrl, + invokeTauri, + signRelayEvent, +} from "@/shared/api/tauri"; // Relay invite data layer. Both endpoints are NIP-98-authed HTTP POSTs // (mirrors the read path in moderation.ts, plus the payload tag the relay @@ -124,26 +128,38 @@ export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean { /** Fetch relay-hosted policy content for any join surface. */ export async function getJoinPolicy( relayWsUrl: string, + transport: "native" | "webview", ): Promise { - const base = relayHttpFromWs(relayWsUrl); - const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`); - // Relays predating join-policy support have no configured policy. - if (response.status === 404) return null; - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const raw = (await response.json()) as { - policy?: { - terms_markdown?: string; - privacy_markdown?: string; - age_attestation_required: boolean; - version: string; - }; + type RawJoinPolicy = { + terms_markdown?: string; + privacy_markdown?: string; + age_attestation_required: boolean; + version: string; }; - return raw.policy + let raw: RawJoinPolicy | null; + if (transport === "native") { + raw = await invokeTauri("fetch_join_policy", { + relayUrl: relayWsUrl, + }); + } else { + const base = relayHttpFromWs(relayWsUrl); + const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`); + // Relays predating join-policy support have no configured policy. + if (response.status === 404) return null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + raw = + ( + (await response.json()) as { + policy?: RawJoinPolicy; + } + ).policy ?? null; + } + return raw ? { - termsMarkdown: raw.policy.terms_markdown, - privacyMarkdown: raw.policy.privacy_markdown, - ageAttestationRequired: raw.policy.age_attestation_required, - version: raw.policy.version, + termsMarkdown: raw.terms_markdown, + privacyMarkdown: raw.privacy_markdown, + ageAttestationRequired: raw.age_attestation_required, + version: raw.version, } : null; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3d89132cd..84a2a3446 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -149,6 +149,13 @@ type E2eConfig = { name?: string; expiresAt: string; } | null; + /** Optional policy returned by the native join-policy discovery command. */ + joinPolicy?: { + terms_markdown?: string; + privacy_markdown?: string; + age_attestation_required: boolean; + version: string; + } | null; /** Delay Builderlab login completion so cancellation/retry UI can be tested. */ builderlabLoginDelayMs?: number; /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ @@ -9511,6 +9518,8 @@ export function maybeInstallE2eTauriMocks() { // seeded empty/default path as valid so Add Community can continue to // relay-policy discovery. return; + case "fetch_join_policy": + return activeConfig?.mock?.joinPolicy ?? null; case "apply_workspace": { const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; if (applyDelayMs > 0) { diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index bfbfc6f06..0c4d63099 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1231,12 +1231,6 @@ test("first-community shows the scenario cards for localhost", async ({ }); test("first-community direct join reaches profile", async ({ page }) => { - await page.route( - "https://onboarding.communities.buzz.xyz/api/join-policy", - async (route) => { - await route.fulfill({ status: 404 }); - }, - ); await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( @@ -1289,12 +1283,6 @@ test("first-community direct join reaches profile", async ({ page }) => { test("first-community direct join cancel returns to request access", async ({ page, }) => { - await page.route( - "https://onboarding.communities.buzz.xyz/api/join-policy", - async (route) => { - await route.fulfill({ status: 404 }); - }, - ); await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 6ff62a214..f56c9575b 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -111,24 +111,15 @@ test("add community starts with create and join choices", async ({ page }) => { test("automatically shows community join requirements near the community URL", async ({ page, }) => { - await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); - await page.route( - "https://policy.example.com/api/join-policy", - async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - policy: { - terms_markdown: "# Terms", - privacy_markdown: "# Privacy", - age_attestation_required: true, - version: "policy-v1", - }, - }), - }); + await installMockBridge(page, { + applyCommunityDelayMs: 1_000, + joinPolicy: { + terms_markdown: "# Terms", + privacy_markdown: "# Privacy", + age_attestation_required: true, + version: "policy-v1", }, - ); + }); await page.goto("/"); await openAddCommunityDialog(page); @@ -180,12 +171,6 @@ test("supports API tokens without cluttering the default join form", async ({ page, }) => { await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); - await page.route( - "https://token.example.com/api/join-policy", - async (route) => { - await route.fulfill({ status: 404 }); - }, - ); await page.goto("/"); await openAddCommunityDialog(page); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d5bd6ae0d..d65a260f3 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -134,6 +134,13 @@ type MockBridgeOptions = { relaySelf?: string | null; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null; + /** Optional policy returned by the native join-policy discovery command. */ + joinPolicy?: { + terms_markdown?: string; + privacy_markdown?: string; + age_attestation_required: boolean; + version: string; + } | null; /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null; /** Communities owned by the mocked Builderlab account. */