mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## Summary Agents hosted inside the Buzz app sign their own events but don't hold a WebSocket, so they couldn't show "typing…" in a channel. `POST /events` rejected kind 20002 (typing indicator) with `restricted: unknown event kind`. Now, when `POST /events` receives a kind 20002 event, the bridge passes it to the same channel broadcast the WebSocket path uses. Live subscribers in the channel get the pulse. Nothing is written to Postgres. Before broadcasting, the relay checks, in order: - the event has an `h` tag naming the channel - the serialized event is at most 2 KiB (real typing events are ~600 bytes). Measuring the whole JSON, not just content and tag values, means piling on empty tags can't get around the cap - the signature and id are valid - bans and timeouts - the community write fence (over HTTP, a quiescing community's host already fails to bind with a 404, so this catches quiescing that starts after the tenant is bound) - the sender is a channel member, or the channel is open (same rule as WebSocket) - the signer is the pubkey that authenticated the request (checked first; a mismatch returns 403) The existing HTTP admission rate limit and relay-membership gate still run before any of this. Other ephemeral kinds are still rejected over HTTP. `publish_channel_ephemeral` is the old WebSocket channel-broadcast code moved into a shared helper, so both paths use the same membership check and fan-out. Unlike the WebSocket path, HTTP requires the `h` tag. Over WebSocket, a 20002 with no channel goes to every subscriber. Points left for review: - HTTP typing pulses count against the same per-pubkey HTTP quota as message posts (default 300/min). One pulse every 3 s is 20/min per active channel. - A failed Redis publish is logged and the POST still returns `accepted: true`, same as the WebSocket path. A pulse is resent every 3 s and expires after 8 s, so a retry would only resend a stale pulse. The `sadscan:disable` comment on an existing test URL in `ingest.rs` is there because the secret scanner reads every file a commit touches. It uses the same form as the other local test fixtures in the repo. ### Related issue Needed for Agents2 typing indicators in buzz-app. The client PR depends on this one and will follow as a draft. ### Testing - 9 new Postgres-backed tests in `api/typing_postgres_tests.rs` cover these cases: a member's pulse is published and not stored; a non-member gets a 400; a timed-out member gets a 403; a signer mismatch gets a 403; a missing `h` tag gets a 400; a tampered event gets a 400; kind 20001 is still rejected; oversized events get a 400 (2 KiB of content, one 2 KiB tag value, or `h` + 10k empty tags); a community that starts quiescing after the tenant is bound gets a 400 `community writes are fenced`, and nothing is published. Two mutation checks: with the fence removed, the fence test fails; with the old content-plus-tag-values cap, the empty-tags case is accepted and the test fails. - The full buzz-relay Postgres lane on27b5a294: 386/387 pass. The one failure is `boot_lifecycle::hanging_redis_peer_times_out_before_the_health_listener_binds`: it went over its timing slack under full-suite load (8.03 s) and passed when rerun alone. - buzz-relay unit tests pass (1476). One mesh demo test timed out once under load and passed when rerun alone. - `cargo clippy -p buzz-relay -p buzz-cli -p buzz-acp --all-targets -D warnings` is clean. - Live test against a local relay built from this branch (27b5a294), run by ART at baxen's request as the sign-off test. alice subscribed over WebSocket to kind 20002 in a private channel; bob (a bot member with no WebSocket) and carol (outsider) used `POST /events` with NIP-98. 16/16 checks passed: - bob's top-level pulse → 200 `accepted: true`, and alice received it live - bob's thread pulse (`h` + root `e` + reply `e`, the buzz-acp shape) → delivered with tags intact - 2 KiB content, a 2 KiB tag value, or `h` + 10k empty tags → 400 `typing indicator too large`; alice received nothing - carol → 400 `restricted: not a channel member`; alice received nothing - carol posting an event signed by bob → 403 - no `h` tag → 400; kind 20001 → 400 `only accepted via WebSocket` - 0 kind-20002 rows in Postgres afterwards 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz> Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz> Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>