mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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.
This commit is contained in:
@@ -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<Url, String> {
|
||||
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<Option<Value>, 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<Value, String> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<JoinPolicy | null> {
|
||||
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<RawJoinPolicy | null>("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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user