Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 7 additions & 1 deletion desktop/biome.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
{
"extends": ["../biome.json"]
"extends": ["../biome.json"],
"overrides": [
{
"includes": ["src-tauri/tests/fixtures/nip_fi/*.json"],
"formatter": { "enabled": false }
}
]
}
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::*;
Expand Down
249 changes: 249 additions & 0 deletions desktop/src-tauri/src/commands/relay_entry_requirements.rs
Original file line number Diff line number Diff line change
@@ -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<Url, String> {
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<bool, String> {
// 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<RelayEntryRequirements, String> {
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<String>) {
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),
("<!doctype html><html>Proxy sign-in</html>", 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}");
}
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions desktop/src-tauri/tests/fixtures/nip_fi/README.md
Original file line number Diff line number Diff line change
@@ -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::<serde_json::Value>(&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.
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
@@ -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"}}
Original file line number Diff line number Diff line change
@@ -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"}}
Loading
Loading