feat: send a User-Agent on relay WebSocket upgrades (#9246)

Relay WebSocket clients in this repo now send a `User-Agent` of the form
`<name>/<version>` on the upgrade request. The version comes from the
build.

| Client | User-Agent |
|---|---|
| `buzz-acp` | `buzz-acp/<crate version>` |
| `buzz-cli` | `buzz-cli/<crate version>` |
| `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` |
| `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` |
| `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` |
| mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` |

- **`buzz-ws-client`**: every connect path sends the default. A
`User-Agent` in the caller's `connect_with_headers` headers replaces it,
so other consumers can identify themselves without any new API.
`buzz-cli` uses this to send its own name.
- **`buzz-audio-client`**: keeps a `User-Agent` already on the
host-supplied request, and adds its default otherwise.
- **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`,
which also covers the huddle transport. Startup waits up to 500 ms for
the app version from `package_info_plus` before the app runs, so the
first socket carries it; if the lookup fails or times out, the value is
`Buzz-Mobile`.

Each client has a test asserting the header on the upgrade request.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-11-08 20:43:55 -05:00
committed by GitHub
parent 31fa53174d
commit 65e864301d
12 changed files with 300 additions and 18 deletions

View File

@@ -423,6 +423,8 @@ test-unit:
# never dialed). Keep the Cargo fallback in step.
cargo nextest run -p buzz-relay-mesh
cargo nextest run -p buzz-cli
cargo nextest run -p buzz-ws-client
cargo nextest run -p buzz-pairing-cli
# buzz-sdk builder/validation unit tests: pure event-builder and input
# validation (e.g. the canvas writer-discipline/skew guard and the
# canvas_write_survived predicate), no infra. `--lib` runs all unit

View File

@@ -133,6 +133,8 @@ use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag};
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::{header::USER_AGENT, HeaderValue};
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tracing::{debug, info, warn};
use uuid::Uuid;
@@ -4204,6 +4206,9 @@ where
Err(last_err.unwrap_or(RelayError::ConnectionClosed))
}
/// `User-Agent` sent on every relay WebSocket upgrade.
const RELAY_USER_AGENT: &str = concat!("buzz-acp/", env!("CARGO_PKG_VERSION"));
/// Perform a single WebSocket connect + NIP-42 auth handshake.
///
/// Returns `(ws, buffer)` on success.
@@ -4216,7 +4221,14 @@ async fn do_connect(
.parse::<url::Url>()
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
let mut request = parsed
.as_str()
.into_client_request()
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
request
.headers_mut()
.insert(USER_AGENT, HeaderValue::from_static(RELAY_USER_AGENT));
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
.await
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
@@ -6375,6 +6387,34 @@ mod tests {
/// WebSocket endpoint, and tungstenite returns `Error::Http` (non-101
/// response) or `Error::Url(UnsupportedUrlScheme)` depending on how far
/// the handshake gets. Either way it must not be retried.
/// The upgrade `do_connect()` sends names buzz-acp and its version.
#[allow(clippy::result_large_err)] // tungstenite's handshake callback type
#[tokio::test]
async fn do_connect_sends_user_agent() {
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut received = None;
let _ws = tokio_tungstenite::accept_hdr_async(
stream,
|request: &Request, response: Response| {
received = request.headers().get(USER_AGENT).cloned();
Ok(response)
},
)
.await
.unwrap();
received
});
// The server hangs up after the upgrade, so the AUTH handshake fails.
let _ = do_connect(&url, &nostr::Keys::generate(), None).await;
assert_eq!(server.await.unwrap().unwrap(), RELAY_USER_AGENT);
assert!(RELAY_USER_AGENT.starts_with("buzz-acp/"));
}
#[tokio::test]
async fn do_connect_wrong_scheme_is_terminal() {
let keys = nostr::Keys::generate();

View File

@@ -5,7 +5,12 @@ use serde_json::{json, Value};
use std::{collections::BTreeSet, future::Future, time::Duration};
use tokio_tungstenite::{
connect_async_tls_with_config,
tungstenite::{client::IntoClientRequest, protocol::WebSocketConfig, Message},
tungstenite::{
client::IntoClientRequest,
http::{header::USER_AGENT, HeaderValue},
protocol::WebSocketConfig,
Message,
},
Connector, MaybeTlsStream, WebSocketStream,
};
@@ -96,11 +101,15 @@ async fn control(socket: &mut AudioSocket) -> Result<Value, String> {
}
}
/// `User-Agent` sent on the upgrade unless the request already carries one.
const DEFAULT_USER_AGENT: &str = concat!("buzz-audio-client/", env!("CARGO_PKG_VERSION"));
/// Connect and authenticate using a host-provided asynchronous signer.
///
/// The host validates the destination before calling, and constructs the NIP-42
/// event (including its exact relay tag) from the challenge. `request` is the
/// audio URL, or a request carrying host-supplied upgrade headers. No private key is
/// audio URL, or a request carrying host-supplied upgrade headers; without a
/// `User-Agent` it sends `buzz-audio-client/<version>`. No private key is
/// accepted. Dropping this future cancels connection/admission; the entire
/// operation is bounded to 15 seconds, including signing.
pub async fn connect<F, Fut>(
@@ -117,6 +126,13 @@ where
let config = WebSocketConfig::default()
.max_message_size(Some(65_536))
.max_frame_size(Some(65_536));
let mut request = request
.into_client_request()
.map_err(|e| format!("audio WS connect failed: {e}"))?;
request
.headers_mut()
.entry(USER_AGENT)
.or_insert(HeaderValue::from_static(DEFAULT_USER_AGENT));
let (mut socket, _) =
connect_async_tls_with_config(request, Some(config), false, connector)
.await

View File

@@ -149,7 +149,11 @@ async fn request_headers_reach_the_upgrade() {
let (stream, _) = listener.accept().await.unwrap();
let (seen, header) = oneshot::channel();
let mut socket = accept_hdr_async(stream, |request: &Request, response: Response| {
let _ = seen.send(request.headers().get("authorization").cloned());
let headers = request.headers();
let _ = seen.send((
headers.get("authorization").cloned(),
headers.get("user-agent").cloned(),
));
Ok(response)
})
.await
@@ -182,8 +186,11 @@ async fn request_headers_reach_the_upgrade() {
})
.await
.unwrap();
assert_eq!(
server.await.unwrap().unwrap().to_str().unwrap(),
"Bearer upgrade-token"
);
let (authorization, user_agent) = server.await.unwrap();
assert_eq!(authorization.unwrap(), "Bearer upgrade-token");
let user_agent = user_agent.unwrap();
assert!(user_agent
.to_str()
.unwrap()
.starts_with("buzz-audio-client/"));
}

View File

@@ -12,6 +12,8 @@ use crate::error::CliError;
/// NIP-FI assertion header (`docs/nips/NIP-FI.md` §Client-attached transport).
const FEDERATED_IDENTITY_HEADER: &str = "Nostr-Federated-Identity";
/// `User-Agent` sent on every WebSocket upgrade.
const USER_AGENT: &str = concat!("buzz-cli/", env!("CARGO_PKG_VERSION"));
/// Descriptor returned by the relay after a successful upload.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -704,10 +706,14 @@ impl BuzzClient {
})
}
/// The NIP-FI assertion as WebSocket upgrade headers (empty without an
/// evidence source).
/// The WebSocket upgrade headers: the CLI's `User-Agent`, and the NIP-FI
/// assertion when there is an evidence source.
async fn upgrade_headers(&self) -> Result<buzz_ws_client::HeaderMap, CliError> {
let mut headers = buzz_ws_client::HeaderMap::new();
headers.insert(
"user-agent",
buzz_ws_client::HeaderValue::from_static(USER_AGENT),
);
if let Some(assertion) = self.evidence().await? {
let mut value =
buzz_ws_client::HeaderValue::from_str(&format!("Bearer {assertion}"))
@@ -4095,6 +4101,20 @@ mod ws_deadline_tests {
pub(super) type Log = Arc<std::sync::Mutex<Vec<String>>>;
/// Every upgrade names the CLI and its version.
#[tokio::test]
async fn upgrade_headers_carry_the_cli_user_agent() {
let keys = Keys::generate();
let c = BuzzClient::new(
"http://127.0.0.1:1".into(),
Credentials::from_signer(Arc::new(keys.clone()), keys.public_key()),
)
.unwrap();
let headers = c.upgrade_headers().await.unwrap();
assert_eq!(headers.get("user-agent").unwrap(), super::USER_AGENT);
assert!(super::USER_AGENT.starts_with("buzz-cli/"));
}
/// A sign-in signature that comes back after the relay's 5 s deadline
/// reconnects and signs the new challenge, never the old one.
#[tokio::test]

View File

@@ -27,6 +27,11 @@ use clap::{Parser, Subcommand};
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Keys, RelayUrl, SecretKey, ToBech32};
use tokio::time::timeout;
use tokio_tungstenite::tungstenite::{
client::IntoClientRequest,
handshake::client::Request,
http::{header::USER_AGENT, HeaderValue},
};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use zeroize::Zeroizing;
@@ -133,7 +138,7 @@ async fn cmd_source(relay_url: String, nsec: Option<String>) -> Result<(), CliEr
// Connect to relay and handle NIP-42 auth if required.
// Auth uses the session's ephemeral keys so the relay accepts our events.
let (ws, _) = connect_async(&relay_url).await?;
let (ws, _) = connect_async(relay_request(&relay_url)?).await?;
let (mut write, mut read) = ws.split();
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
@@ -235,7 +240,7 @@ async fn cmd_target(relay_override: Option<String>, show_secret: bool) -> Result
let (mut session, offer_event) = PairingSession::new_target(&qr)?;
// Connect to relay and handle NIP-42 auth if required.
let (ws, _) = connect_async(&relay_url).await?;
let (ws, _) = connect_async(relay_request(&relay_url)?).await?;
let (mut write, mut read) = ws.split();
handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?;
@@ -629,3 +634,27 @@ fn hex_to_32(s: &str) -> Result<[u8; 32], CliError> {
.try_into()
.map_err(|_| CliError::Other(format!("expected 32 bytes, got wrong length for '{s}'")))
}
/// `User-Agent` sent on the relay WebSocket upgrade.
const RELAY_USER_AGENT: &str = concat!("buzz-pairing-cli/", env!("CARGO_PKG_VERSION"));
/// The relay upgrade request for `url`, naming this tool and its version.
fn relay_request(url: &str) -> Result<Request, tokio_tungstenite::tungstenite::Error> {
let mut request = url.into_client_request()?;
request
.headers_mut()
.insert(USER_AGENT, HeaderValue::from_static(RELAY_USER_AGENT));
Ok(request)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relay_request_carries_user_agent() {
let request = relay_request("wss://relay.example.com").unwrap();
assert_eq!(request.headers()[USER_AGENT], RELAY_USER_AGENT);
assert!(RELAY_USER_AGENT.starts_with("buzz-pairing-cli/"));
}
}

View File

@@ -6,7 +6,8 @@ use nostr::{Event, NostrSigner, Tag};
use serde_json::{json, Value};
use tokio::time::timeout;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderMap;
use tokio_tungstenite::tungstenite::handshake::client::Request;
use tokio_tungstenite::tungstenite::http::{header::USER_AGENT, HeaderMap, HeaderValue};
use tokio_tungstenite::{
client_async, connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream,
};
@@ -40,6 +41,19 @@ async fn phase<T>(
.map_err(|_| WsClientError::Timeout)?
}
/// `User-Agent` sent on the upgrade unless the caller's headers supply one.
const DEFAULT_USER_AGENT: &str = concat!("buzz-ws-client/", env!("CARGO_PKG_VERSION"));
/// The upgrade request for `url`: the default `User-Agent`, then `headers`.
fn upgrade_request(url: &url::Url, headers: HeaderMap) -> Result<Request, WsClientError> {
let mut request = url.as_str().into_client_request()?;
request
.headers_mut()
.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT));
request.headers_mut().extend(headers);
Ok(request)
}
/// A NIP-42-capable WebSocket connection to a Nostr relay.
pub struct NostrWsConnection {
ws: WsStream,
@@ -85,7 +99,8 @@ impl NostrWsConnection {
/// [`Self::connect`], sending `headers` on the WebSocket upgrade request.
///
/// The headers are added after the handshake headers, so supplying a handshake
/// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade.
/// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade. A
/// `User-Agent` among them replaces the default `buzz-ws-client/<version>`.
pub async fn connect_with_headers(
url: &str,
headers: HeaderMap,
@@ -94,8 +109,7 @@ impl NostrWsConnection {
.parse::<url::Url>()
.map_err(|e| WsClientError::Url(e.to_string()))?;
let mut request = parsed.as_str().into_client_request()?;
request.headers_mut().extend(headers);
let request = upgrade_request(&parsed, headers)?;
let (ws, _response) = connect_async(request)
.await
@@ -117,7 +131,7 @@ impl NostrWsConnection {
let parsed = url
.parse::<url::Url>()
.map_err(|e| WsClientError::Url(e.to_string()))?;
let request = parsed.as_str().into_client_request()?;
let request = upgrade_request(&parsed, HeaderMap::new())?;
let tcp = tokio::net::TcpStream::connect(addr)
.await
.map_err(|e| WsClientError::WebSocket(e.into()))?;
@@ -426,7 +440,6 @@ mod tests {
#[tokio::test]
async fn connect_with_headers_sends_headers_on_upgrade() {
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use tokio_tungstenite::tungstenite::http::HeaderValue;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}", listener.local_addr().unwrap());
@@ -460,6 +473,56 @@ mod tests {
);
}
/// The `User-Agent` a relay sees on an upgrade sent with `headers`, through
/// `connect_with_headers` or, with `via_addr`, `connect_to_addr`.
#[allow(clippy::result_large_err)]
async fn upgrade_user_agent(headers: HeaderMap, via_addr: bool) -> Option<HeaderValue> {
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut received = None;
let _ws = tokio_tungstenite::accept_hdr_async(
stream,
|request: &Request, response: Response| {
received = request.headers().get(USER_AGENT).cloned();
Ok(response)
},
)
.await
.unwrap();
received
});
let url = format!("ws://{addr}");
let _conn = if via_addr {
NostrWsConnection::connect_to_addr(&url, &addr).await
} else {
NostrWsConnection::connect_with_headers(&url, headers).await
}
.unwrap();
server.await.unwrap()
}
#[tokio::test]
async fn upgrade_sends_default_user_agent() {
let expected = Some(HeaderValue::from_static(DEFAULT_USER_AGENT));
assert_eq!(upgrade_user_agent(HeaderMap::new(), false).await, expected);
assert_eq!(upgrade_user_agent(HeaderMap::new(), true).await, expected);
assert!(DEFAULT_USER_AGENT.starts_with("buzz-ws-client/"));
}
#[tokio::test]
async fn caller_user_agent_replaces_default() {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("example/1.0"));
assert_eq!(
upgrade_user_agent(headers, false).await,
Some(HeaderValue::from_static("example/1.0"))
);
}
/// A relay that answers NIP-42 AUTH by closing with `code`, as the relay
/// does on a NIP-FI key mismatch (1008).
async fn close_after_auth(code: u16) -> WsClientError {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
@@ -11,6 +12,7 @@ import 'features/invites/invite_join_provider.dart';
import 'shared/agents/agents_surface.dart';
import 'shared/push/push_bridge.dart';
import 'shared/relay/relay_closed_policy.dart';
import 'shared/relay/relay_websocket.dart';
import 'shared/theme/theme_provider.dart';
void main() => runBuzzApp(const App());
@@ -18,9 +20,14 @@ void main() => runBuzzApp(const App());
Future<void> runBuzzApp(Widget app) async {
WidgetsFlutterBinding.ensureInitialized();
installBuzzPushMethodHandler();
// Relay upgrades name the app version, so learn it before any socket opens.
final userAgent = loadRelayUserAgent(
() async => (await PackageInfo.fromPlatform()).version,
);
// Pre-load preferences so the first frame uses the saved theme/accent.
final prefs = await SharedPreferences.getInstance();
await userAgent;
runApp(buildRootProviderScope(prefs: prefs, child: app));
}

View File

@@ -8,6 +8,26 @@ import 'dart:io';
/// leaves the app reconnecting for as long as the network stays stalled.
const relayWebSocketConnectTimeout = Duration(seconds: 10);
/// `User-Agent` sent on every relay WebSocket upgrade; [setRelayUserAgent]
/// adds the app version once it is known.
String relayUserAgent = 'Buzz-Mobile';
void setRelayUserAgent(String version) =>
relayUserAgent = 'Buzz-Mobile/$version';
/// Sets [relayUserAgent] from [lookupVersion] before any socket opens, giving
/// up after [timeout] so a hung or failed lookup cannot stall launch.
Future<void> loadRelayUserAgent(
Future<String> Function() lookupVersion, {
Duration timeout = const Duration(milliseconds: 500),
}) async {
try {
setRelayUserAgent(await lookupVersion().timeout(timeout));
} catch (_) {
// Keep the unversioned fallback.
}
}
/// WebSocket's default HTTP client follows redirects, including custom headers.
/// Keep key-bound assertions at their original origin and bound upgrade time.
///
@@ -20,6 +40,7 @@ Future<WebSocket> openRelayWebSocket(
Duration connectTimeout = relayWebSocketConnectTimeout,
}) async {
final HttpClient client = headers.isEmpty ? HttpClient() : _UpgradeClient();
client.userAgent = relayUserAgent;
try {
return await WebSocket.connect(
uri.toString(),
@@ -51,6 +72,12 @@ class _UpgradeClient implements HttpClient {
return request;
}
@override
String? get userAgent => _inner.userAgent;
@override
set userAgent(String? value) => _inner.userAgent = value;
@override
void close({bool force = false}) => _inner.close(force: force);

View File

@@ -20,6 +20,7 @@ import 'package:buzz/shared/push/push_bootstrap.dart';
import 'package:buzz/shared/push/push_bridge.dart';
import 'package:buzz/shared/push/push_subscription.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:buzz/shared/push/push_lease_revocation_outbox.dart';
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
@@ -339,6 +340,13 @@ void main() {
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
SharedPreferences.setMockInitialValues({});
PackageInfo.setMockInitialValues(
appName: 'Buzz',
packageName: 'example.buzz',
version: '1.0.0',
buildNumber: '1',
buildSignature: '',
);
const channel = MethodChannel('buzz/push');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;

View File

@@ -305,4 +305,61 @@ void main() {
await sink.close(force: true);
},
);
test('upgrades name the app and its version', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final agents = <String?>[];
server.listen((request) async {
agents.add(request.headers.value(HttpHeaders.userAgentHeader));
final ws = await WebSocketTransformer.upgrade(request);
ws.listen((_) {}, onError: (_) {}, onDone: () {});
});
setRelayUserAgent('1.2.3');
for (final headers in [
<String, String>{},
{'Nostr-Federated-Identity': 'Bearer synthetic'},
]) {
final socket = await openRelayWebSocket(
Uri.parse('ws://127.0.0.1:${server.port}'),
headers: headers,
pingInterval: const Duration(seconds: 30),
);
await socket.close();
}
expect(agents, ['Buzz-Mobile/1.2.3', 'Buzz-Mobile/1.2.3']);
await server.close(force: true);
});
test('first upgrade waits for a delayed version lookup', () async {
final agents = <String?>[];
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) async {
agents.add(request.headers.value(HttpHeaders.userAgentHeader));
final ws = await WebSocketTransformer.upgrade(request);
ws.listen((_) {}, onError: (_) {}, onDone: () {});
});
relayUserAgent = 'Buzz-Mobile';
await loadRelayUserAgent(
() => Future.delayed(const Duration(milliseconds: 100), () => '4.5.6'),
);
final socket = await openRelayWebSocket(
Uri.parse('ws://127.0.0.1:${server.port}'),
headers: const {},
pingInterval: const Duration(seconds: 30),
);
await socket.close();
expect(agents, ['Buzz-Mobile/4.5.6']);
await server.close(force: true);
});
test('a hung or failed version lookup keeps the unversioned agent', () async {
relayUserAgent = 'Buzz-Mobile';
await loadRelayUserAgent(
() => Completer<String>().future,
timeout: const Duration(milliseconds: 10),
);
expect(relayUserAgent, 'Buzz-Mobile');
await loadRelayUserAgent(() async => throw StateError('no platform'));
expect(relayUserAgent, 'Buzz-Mobile');
});
}

View File

@@ -113,6 +113,12 @@ run_unit_tests() {
run_test_step "buzz-cli tests" \
cargo test -p buzz-cli -- --nocapture
run_test_step "buzz-ws-client tests" \
cargo test -p buzz-ws-client -- --nocapture
run_test_step "buzz-pairing-cli tests" \
cargo test -p buzz-pairing-cli -- --nocapture
# buzz-sdk builder/validation unit tests: pure event-builder and input
# validation, no infra. Mirrors the nextest path in `just test-unit` — the
# two lists must stay in step. `--lib` matches the nextest invocation and