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:
John Matthew Tennant
2026-07-27 17:52:36 -04:00
committed by GitHub
parent 7ca0bbd946
commit 0019f80765
11 changed files with 292 additions and 62 deletions
@@ -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"
);
}
}
+2
View File
@@ -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::*;
+1
View File
@@ -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;
+33 -4
View File
@@ -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;
}
});
+34 -18
View File
@@ -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;
}
+9
View File
@@ -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) {
-12
View File
@@ -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(
+8 -23
View File
@@ -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);
+7
View File
@@ -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. */