diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ac387817f6..07dc4cce55 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -96,8 +96,8 @@ fn current_working_directory() -> Result { /// Publish a kind:20001 presence update event via the WebSocket connection. /// -/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence -/// updates must be routed through the WS path. +/// The HTTP bridge rejects presence (kind:20001); typing (kind:20002) is the +/// only ephemeral kind it accepts. Presence updates must use the WS path. /// /// Content is a bare status string (`"online"`, `"away"`, `"offline"`) matching /// the desktop client's format. The relay stores this in Redis and synthesizes diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index b7fa06d203..2fd49d851e 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -333,7 +333,7 @@ buzz users presence --pubkeys "$MY_PUBKEY" | jq . buzz users set-presence --status online | jq . buzz users set-presence --status away | jq . buzz users set-presence --status offline | jq . -# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge +# Note: set-presence may fail — the HTTP bridge accepts kind:20001 (presence) only via WebSocket # users set-status — NIP-38 kind:30315 on the d:general coordinate buzz users set-status --text "reviewing PRs" --emoji "🔍" | jq . diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 54441f55cc..28197f3923 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1174,7 +1174,8 @@ impl BuzzClient { /// Publish an ephemeral event via WebSocket with NIP-42 authentication. /// - /// The relay rejects ephemeral kinds (20000–29999) over HTTP. Delegates to + /// The relay accepts only typing (kind:20002) of the ephemeral kinds + /// (20000–29999) over HTTP. Delegates to /// `buzz_ws_client::publish_event` which handles connect, NIP-42 auth, /// EVENT send, OK wait, and graceful close. pub async fn publish_ephemeral_event(&self, event: nostr::Event) -> Result { diff --git a/crates/buzz-relay/src/api/typing_postgres_tests.rs b/crates/buzz-relay/src/api/typing_postgres_tests.rs index 9e9c700957..3284d82b2d 100644 --- a/crates/buzz-relay/src/api/typing_postgres_tests.rs +++ b/crates/buzz-relay/src/api/typing_postgres_tests.rs @@ -207,17 +207,99 @@ async fn typing_without_channel_is_rejected() { #[ignore = "requires Postgres and Redis"] async fn oversized_typing_is_rejected() { let f = Fixture::new().await; - let event = EventBuilder::new(Kind::Custom(20002), "x".repeat(2048)) - .tags([Tag::parse(["h".to_string(), f.channel.to_string()]).unwrap()]) - .sign_with_keys(&f.member) + let h = || Tag::parse(["h".to_string(), f.channel.to_string()]).unwrap(); + let empty = || Tag::parse([String::new()]).unwrap(); + let cases = [ + ("content", "x".repeat(2048), vec![h()]), + ( + "tag value", + String::new(), + vec![ + h(), + Tag::parse(["e".to_string(), "x".repeat(2048)]).unwrap(), + ], + ), + ( + "empty tags", + String::new(), + std::iter::once(h()) + .chain(std::iter::repeat_with(empty).take(10_000)) + .collect(), + ), + ]; + for (case, content, tags) in cases { + let event = EventBuilder::new(Kind::Custom(20002), content) + .tags(tags) + .sign_with_keys(&f.member) + .unwrap(); + let (status, body) = f.post(&f.member, &event).await; + assert_eq!( + status, + axum::http::StatusCode::BAD_REQUEST, + "{case}: {body}" + ); + assert!( + body["error"] + .as_str() + .unwrap_or_default() + .contains("too large"), + "{case}: {body}" + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn fenced_community_typing_is_rejected() { + use futures::StreamExt; + let f = Fixture::new().await; + // Over HTTP a quiescing community's host no longer binds (404), so the + // fence only matters when quiescing starts after the request has bound its + // tenant. Reproduce that order: bind first, then quiesce, then publish. + let tenant = crate::tenant::bind_community(&f.state.db, &f.host) + .await .unwrap(); - let (status, body) = f.post(&f.member, &event).await; - assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + // Enter the deletion executor's transaction scope in this disposable + // fixture; the DB rejects unfenced ad-hoc state changes. + let mut tx = f.pool.begin().await.unwrap(); + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '0', true)") + .bind(f.community.to_string()) + .execute(&mut *tx) + .await + .unwrap(); + sqlx::query("UPDATE communities SET deletion_state = 'quiescing' WHERE id = $1") + .bind(f.community.as_uuid()) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let topic = buzz_pubsub::EventTopicKey { + community_id: f.community, + topic: buzz_pubsub::EventTopic::Channel(f.channel), + } + .redis_channel(); + let client = redis::Client::open(f.state.config.redis_url.as_str()).unwrap(); + let mut pubsub = client.get_async_pubsub().await.unwrap(); + pubsub.subscribe(&topic).await.unwrap(); + + let err = crate::handlers::event::publish_http_typing( + &f.state, + &tenant, + f.typing(&f.member, Some(f.channel)), + f.member.public_key(), + ) + .await + .unwrap_err(); assert!( - body["error"] - .as_str() - .unwrap_or_default() - .contains("too large"), - "{body}" + matches!(&err, IngestError::Rejected(msg) if msg == "restricted: community writes are fenced"), + "{err:?}" + ); + let mut messages = pubsub.into_on_message(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(500), messages.next()) + .await + .is_err(), + "fenced typing indicator must not be published" ); } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index f6cbe7af7a..64a56d4651 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1056,11 +1056,13 @@ async fn publish_channel_ephemeral( Ok(()) } -/// Upper bound on content plus tag values for an HTTP typing indicator. -/// Real indicators carry empty content and at most three short tags (`h`, -/// root `e`, reply `e`, roughly 250 bytes). Without a cap a member could post -/// up to the HTTP body limit and have it fanned out to every subscriber. -const MAX_HTTP_TYPING_BYTES: usize = 1024; +/// Upper bound on the serialized JSON of an HTTP typing indicator. Real +/// indicators carry empty content and at most three short tags (`h`, root +/// `e`, reply `e`), about 600 bytes with id, pubkey and sig. Measuring the +/// serialized event, not just content and tag values, also counts tag +/// structure, so many empty tags cannot slip a near-body-limit event past the +/// cap and into fan-out to every subscriber. +const MAX_HTTP_TYPING_BYTES: usize = 2048; /// Publishes a typing indicator (kind:20002) submitted through HTTP /// `POST /events`, for clients that sign but hold no WebSocket, such as @@ -1081,16 +1083,9 @@ pub(crate) async fn publish_http_typing( let ch_id = super::ingest::extract_channel_id(&event).ok_or_else(|| { IngestError::Rejected("invalid: typing indicator needs a channel UUID h tag".into()) })?; - let size = event.content.len() - + event - .tags - .iter() - .flat_map(|tag| tag.as_slice()) - .map(String::len) - .sum::(); - if size > MAX_HTTP_TYPING_BYTES { + if nostr::JsonUtil::as_json(&event).len() > MAX_HTTP_TYPING_BYTES { return Err(IngestError::Rejected(format!( - "invalid: typing indicator too large (max {MAX_HTTP_TYPING_BYTES} bytes of content and tags)" + "invalid: typing indicator too large (max {MAX_HTTP_TYPING_BYTES} bytes serialized)" ))); } let event_clone = event.clone(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 25490ace3e..d093d139a8 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2390,6 +2390,8 @@ async fn ingest_event_inner( )); } + // Typing indicators (kind:20002) never reach this gate: the HTTP bridge + // routes them to `publish_http_typing` before ingest. if auth.is_http() && (kind_u32 == KIND_GIFT_WRAP || kind_u32 == KIND_PRESENCE_UPDATE) { return Err(IngestError::Rejected(format!( "invalid: kind {kind_u32} is only accepted via WebSocket" diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 05e02a4f2d..bb40fbff1a 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -122,7 +122,7 @@ buzz messages send --channel \ ## Gotchas 1. **`feed get` sorts newest-first** — every other list command sorts oldest-first. Don't assume consistent sort order. -2. **`users set-presence` is broken** — sends ephemeral kind:20001 via HTTP POST; relay rejects ephemeral kinds over HTTP. Will fail until WebSocket support is added. +2. **`users set-presence` is broken** — sends ephemeral kind:20001 via HTTP POST; relay accepts presence only over WebSocket (typing, kind:20002, is the one ephemeral kind HTTP accepts). Will fail until WebSocket support is added. 3. **`workflow runs` always returns `[]`** — run history lives in the relay's database, not as Nostr events. 4. **`dms open` returns `dm_id`** — use this value as `--channel` for subsequent `messages send/get` commands on that DM. 5. **Content max 65,536 bytes** (exit 1 if exceeded). Diffs auto-truncate at 61,440 bytes at a hunk boundary.