mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
mesh-llm plan v6.1: Step 3 — embedded iroh-relay with NIP-98 admission
- New module crates/sprout-relay/src/iroh_relay.rs (~290 lines incl. tests).
- pub fn spawn(state, bind_addr) constructs an iroh_relay::server::Server
with AccessConfig::Restricted set to a closure that:
1. Pulls the Bearer token from ClientRequest::auth_token().
2. base64-decodes (accepts STANDARD + URL_SAFE, padded or not).
3. Calls sprout_auth::verify_nip98_event against canonical URL
(= sprout_auth::nip98_canonical_url(public_url, '/relay')).
4. Runs check_relay_membership against the NIP-98 pubkey.
Anything other than Member/ViaOwner/OpenRelay -> Deny.
Per Max's review notes: fail-closed on missing/invalid token, run
membership only after NIP-98 verifies the pubkey, no caching.
- Returns Ok(None) gracefully when SPROUT_IROH_RELAY_PUBLIC_URL is unset
(the canonical URL can't be built without it).
- patched-iroh-relay feature flag reserved for upstream PR C's per-client
max-lifetime hook (kept behind cfg so unpatched rc.0 still compiles).
- MSRV bumped from 1.88.0 -> 1.91.0 (iroh-relay rc.0's MSRV). Repo's
rust-toolchain.toml already pins 1.95.0 so builds are unaffected; the
bump just keeps Cargo.toml honest with the actual transitive floor.
- README updated: 'Rust 1.88+' -> 'Rust 1.91+'.
- crates/sprout-relay/Cargo.toml: added
iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] }
plus the patched-iroh-relay feature.
Tests (rustc 1.95, via rust-toolchain.toml; also verified independently
on 1.91.1):
- sprout-relay --lib: 195 -> 206 (+11 iroh_relay tests covering valid
admission, missing/empty/non-base64/wrong-method/wrong-URL/wrong-kind/
stale-timestamp denials, and bearer-encoding round-trips).
- cargo clippy --workspace --all-targets -- -D warnings: clean.
- cargo fmt --all -- --check: clean.
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Generated
+1366
-14
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -32,7 +32,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88.0"
|
||||
rust-version = "1.91.0"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/sprout-rs/sprout"
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ Agents are colleagues, not haunted cron jobs.
|
||||
|
||||
## Quick start
|
||||
|
||||
You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`).
|
||||
You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.91+, Node 24+, pnpm 10+, `just`).
|
||||
|
||||
**Once:**
|
||||
```bash
|
||||
|
||||
@@ -58,9 +58,14 @@ url = { workspace = true }
|
||||
moka = { workspace = true }
|
||||
metrics = { workspace = true }
|
||||
metrics-exporter-prometheus = { workspace = true }
|
||||
iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] }
|
||||
|
||||
[features]
|
||||
dev = ["sprout-auth/dev"]
|
||||
# Enables APIs that only exist on a locally-patched fork of iroh-relay
|
||||
# (notably the per-client max lifetime hook used by Step 3). Off by default
|
||||
# so the published rc.0 crate compiles unmodified.
|
||||
patched-iroh-relay = []
|
||||
|
||||
[dev-dependencies]
|
||||
sprout-core = { workspace = true, features = ["test-utils"] }
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
//! Embedded iroh-relay server, gated by Sprout relay membership.
|
||||
//!
|
||||
//! This is the **Step 3** half of the mesh-LLM plan (v6.1). The desktop
|
||||
//! sidecar connects its iroh endpoint to `iroh_relay_url` advertised in the
|
||||
//! Sprout NIP-11 document; this module hosts that relay endpoint inside the
|
||||
//! Sprout process and gates every connection with the same NIP-98 + relay-
|
||||
//! membership check we already use for HTTP entry points.
|
||||
//!
|
||||
//! The result: mesh-LLM QUIC traffic never leaves the relay's trust boundary
|
||||
//! and **n0's public relays are never in the path**. No subscriptions, no
|
||||
//! signups, no out-of-band config — relay members get pooled compute "for
|
||||
//! free" once they install Sprout.
|
||||
//!
|
||||
//! # Access flow
|
||||
//!
|
||||
//! 1. The iroh client opens a WebSocket to `https://<relay>/iroh/relay`
|
||||
//! carrying `Authorization: Bearer <base64(NIP-98 event JSON)>` and its
|
||||
//! proven `EndpointId` (proved by iroh-relay's handshake before we run).
|
||||
//! 2. iroh-relay calls our [`AccessConfig::Restricted`] callback with the
|
||||
//! [`ClientRequest`].
|
||||
//! 3. We verify the NIP-98 event against the canonical relay URL using
|
||||
//! [`sprout_auth::nip98_canonical_url`] + [`sprout_auth::verify_nip98_event`].
|
||||
//! Any failure → `Access::Deny`. This proves the connecting pubkey.
|
||||
//! 4. We run [`crate::api::relay_members::check_relay_membership`] against
|
||||
//! that pubkey. Anything other than `OpenRelay`/`Member`/`ViaOwner` →
|
||||
//! `Access::Deny`.
|
||||
//! 5. We log the bound (NIP-98 pubkey, EndpointId, decision) for audit.
|
||||
//!
|
||||
//! No state is cached: every connection re-runs steps 3 + 4. The cost is one
|
||||
//! Schnorr verify and 1-2 DB reads per connection, which is negligible
|
||||
//! versus the QUIC + model traffic that follows.
|
||||
//!
|
||||
//! # Patched-fork hooks
|
||||
//!
|
||||
//! Upstream iroh-relay rc.0 does not expose a per-client maximum-lifetime
|
||||
//! hook. A locally-patched fork (`upstream PR C` in the plan) adds one so we
|
||||
//! can force re-auth every N minutes. We isolate that wiring behind
|
||||
//! [`cfg(feature = "patched-iroh-relay")`] so the unpatched crate compiles
|
||||
//! cleanly.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use iroh_relay::server::{
|
||||
Access, AccessConfig, ClientRequest, RelayConfig, Server, ServerConfig, SpawnError,
|
||||
};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::api::relay_members::{check_relay_membership, MembershipDecision};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Path component appended to `iroh_relay_public_url` for the access check.
|
||||
///
|
||||
/// Iroh's WebSocket upgrade always hits `/relay`; if the relay is reverse-
|
||||
/// proxied under a path prefix (e.g. `https://host/iroh`), the full canonical
|
||||
/// URL is `https://host/iroh/relay`. The NIP-98 signer and verifier both
|
||||
/// compute this via [`sprout_auth::nip98_canonical_url`].
|
||||
pub const IROH_RELAY_PATH: &str = "/relay";
|
||||
|
||||
/// HTTP method bound into NIP-98 events for iroh-relay connection auth.
|
||||
const NIP98_METHOD: &str = "GET";
|
||||
|
||||
/// Handle returned by [`spawn`] — dropping it stops the server.
|
||||
pub struct IrohRelayHandle {
|
||||
/// The bound HTTP address (resolved if the caller passed port 0).
|
||||
pub http_addr: Option<SocketAddr>,
|
||||
/// The bound HTTPS address, if TLS was configured.
|
||||
pub https_addr: Option<SocketAddr>,
|
||||
_server: Server,
|
||||
}
|
||||
|
||||
/// Spawn an embedded iroh-relay bound to `bind_addr`, gated by Sprout's
|
||||
/// NIP-98 + relay-membership check.
|
||||
///
|
||||
/// Returns `Ok(None)` if `state.config.iroh_relay_public_url` is not set:
|
||||
/// without a stable public URL the NIP-98 `u`-tag can't be canonicalised, so
|
||||
/// hosting an iroh-relay endpoint would just produce an undebuggable storm
|
||||
/// of `URL mismatch` denials. We surface that as "not enabled" instead.
|
||||
pub async fn spawn(
|
||||
state: Arc<AppState>,
|
||||
bind_addr: SocketAddr,
|
||||
) -> Result<Option<IrohRelayHandle>, SpawnError> {
|
||||
let Some(public_url) = state.config.iroh_relay_public_url.clone() else {
|
||||
info!("SPROUT_IROH_RELAY_PUBLIC_URL not set — embedded iroh-relay disabled");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let canonical_url = match sprout_auth::nip98_canonical_url(&public_url, IROH_RELAY_PATH) {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
warn!(
|
||||
public_url = %public_url,
|
||||
"SPROUT_IROH_RELAY_PUBLIC_URL is not a parseable URL — iroh-relay disabled",
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
bind_addr = %bind_addr,
|
||||
canonical_url = %canonical_url,
|
||||
"spawning embedded iroh-relay",
|
||||
);
|
||||
|
||||
let access = build_access_config(state.clone(), canonical_url);
|
||||
|
||||
let mut relay = RelayConfig::new(bind_addr);
|
||||
relay.access = access;
|
||||
|
||||
let mut cfg = ServerConfig::default();
|
||||
cfg.relay = Some(relay);
|
||||
|
||||
let server = Server::spawn(cfg).await?;
|
||||
Ok(Some(IrohRelayHandle {
|
||||
http_addr: server.http_addr(),
|
||||
https_addr: server.https_addr(),
|
||||
_server: server,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build the [`AccessConfig::Restricted`] callback that gates every
|
||||
/// iroh-relay connection on (NIP-98 ∧ relay-membership).
|
||||
fn build_access_config(state: Arc<AppState>, canonical_url: String) -> AccessConfig {
|
||||
AccessConfig::Restricted(Box::new(move |request: &ClientRequest| {
|
||||
let state = state.clone();
|
||||
let canonical_url = canonical_url.clone();
|
||||
let endpoint_id = request.endpoint_id();
|
||||
let auth_token = request.auth_token();
|
||||
Box::pin(async move {
|
||||
match decide(&state, &canonical_url, auth_token.as_deref()).await {
|
||||
Decision::Allow { pubkey, owner } => {
|
||||
debug!(
|
||||
endpoint = %endpoint_id,
|
||||
pubkey = %pubkey,
|
||||
via_owner = ?owner,
|
||||
"iroh-relay admission allowed",
|
||||
);
|
||||
Access::Allow
|
||||
}
|
||||
Decision::Deny(reason) => {
|
||||
debug!(
|
||||
endpoint = %endpoint_id,
|
||||
reason = %reason,
|
||||
"iroh-relay admission denied",
|
||||
);
|
||||
Access::Deny
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// Internal decision type for the access callback — kept separate so it's
|
||||
/// straightforward to unit-test [`decide`] without spinning up a full server.
|
||||
#[derive(Debug)]
|
||||
enum Decision {
|
||||
/// Connection should be admitted.
|
||||
Allow {
|
||||
/// The NIP-98-proven pubkey of the connecting client.
|
||||
pubkey: nostr::PublicKey,
|
||||
/// `Some(owner)` if admission was via NIP-OA delegation.
|
||||
owner: Option<nostr::PublicKey>,
|
||||
},
|
||||
/// Connection should be rejected, with a debug-only reason string.
|
||||
Deny(String),
|
||||
}
|
||||
|
||||
/// Pure-logic admission decision, decoupled from iroh-relay's types so it
|
||||
/// can be unit-tested with a real [`AppState`] and a synthetic bearer token.
|
||||
async fn decide(state: &AppState, canonical_url: &str, auth_token: Option<&str>) -> Decision {
|
||||
// Step 1+2+3 — extract and verify the NIP-98 bearer to recover the
|
||||
// Nostr pubkey. This sub-function is unit-testable in isolation.
|
||||
let pubkey = match verify_bearer(canonical_url, auth_token) {
|
||||
Ok(pk) => pk,
|
||||
Err(reason) => return Decision::Deny(reason),
|
||||
};
|
||||
|
||||
// Step 4 — now and only now, run the membership check. We pass the NIP-98
|
||||
// pubkey bytes, not the iroh `EndpointId` — the latter is just an
|
||||
// anonymous network identifier; membership is on Nostr identity.
|
||||
match check_relay_membership(state, &pubkey.to_bytes(), None).await {
|
||||
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Decision::Allow {
|
||||
pubkey,
|
||||
owner: None,
|
||||
},
|
||||
Ok(MembershipDecision::ViaOwner(owner)) => Decision::Allow {
|
||||
pubkey,
|
||||
owner: Some(owner),
|
||||
},
|
||||
Ok(MembershipDecision::Denied) => Decision::Deny(format!("not a relay member: {}", pubkey)),
|
||||
Err(e) => {
|
||||
// Infrastructure failure. Fail closed.
|
||||
warn!("iroh-relay membership check infra error: {e}");
|
||||
Decision::Deny(format!("membership check infra error: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode + verify the bearer token, returning the proven Nostr pubkey.
|
||||
///
|
||||
/// Fail-closed on:
|
||||
/// - missing/empty token,
|
||||
/// - non-base64,
|
||||
/// - non-UTF-8 JSON,
|
||||
/// - any NIP-98 verification failure (wrong kind, bad signature, stale
|
||||
/// timestamp, URL mismatch, method mismatch, payload mismatch).
|
||||
///
|
||||
/// The returned `String` is a debug-only deny reason; do not forward to
|
||||
/// clients (some failures distinguish "what" from "why" in ways we don't
|
||||
/// want to leak).
|
||||
fn verify_bearer(
|
||||
canonical_url: &str,
|
||||
auth_token: Option<&str>,
|
||||
) -> Result<nostr::PublicKey, String> {
|
||||
let token = match auth_token {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Err("missing or empty bearer token".to_string()),
|
||||
};
|
||||
|
||||
let json =
|
||||
decode_bearer(token).ok_or_else(|| "bearer token is not valid base64".to_string())?;
|
||||
|
||||
sprout_auth::verify_nip98_event(&json, canonical_url, NIP98_METHOD, None)
|
||||
.map_err(|e| format!("NIP-98 verification failed: {e}"))
|
||||
}
|
||||
|
||||
/// Decode a NIP-98 bearer token. NIP-98 specifies base64 over the JSON event;
|
||||
/// some signers use URL-safe encoding and/or omit padding, so we accept both.
|
||||
fn decode_bearer(token: &str) -> Option<String> {
|
||||
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
|
||||
use base64::Engine;
|
||||
|
||||
let trimmed = token.trim();
|
||||
for engine in [&STANDARD, &URL_SAFE, &STANDARD_NO_PAD, &URL_SAFE_NO_PAD] {
|
||||
if let Ok(bytes) = engine.decode(trimmed) {
|
||||
if let Ok(s) = String::from_utf8(bytes) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||||
use sprout_auth::nip98_canonical_url;
|
||||
|
||||
/// Build a signed NIP-98 event JSON for the given canonical URL.
|
||||
fn signed_event_json(keys: &Keys, canonical_url: &str, method: &str) -> String {
|
||||
let event = EventBuilder::new(
|
||||
Kind::HttpAuth,
|
||||
"",
|
||||
vec![
|
||||
Tag::parse(&["u", canonical_url]).unwrap(),
|
||||
Tag::parse(&["method", method]).unwrap(),
|
||||
],
|
||||
)
|
||||
.custom_created_at(Timestamp::now())
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
serde_json::to_string(&event).unwrap()
|
||||
}
|
||||
|
||||
fn bearer(json: &str) -> String {
|
||||
STANDARD.encode(json)
|
||||
}
|
||||
|
||||
fn canonical() -> String {
|
||||
nip98_canonical_url("https://relay.example.com/iroh", IROH_RELAY_PATH).unwrap()
|
||||
}
|
||||
|
||||
// ── Bearer verification ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_accepts_valid_nip98() {
|
||||
let keys = Keys::generate();
|
||||
let url = canonical();
|
||||
let json = signed_event_json(&keys, &url, NIP98_METHOD);
|
||||
let token = bearer(&json);
|
||||
|
||||
let result = verify_bearer(&url, Some(&token));
|
||||
assert!(result.is_ok(), "expected accept, got {result:?}");
|
||||
assert_eq!(result.unwrap(), keys.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_missing_token() {
|
||||
let url = canonical();
|
||||
let result = verify_bearer(&url, None);
|
||||
assert!(matches!(result, Err(ref e) if e.contains("missing")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_empty_token() {
|
||||
let url = canonical();
|
||||
let result = verify_bearer(&url, Some(""));
|
||||
assert!(matches!(result, Err(ref e) if e.contains("missing")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_non_base64() {
|
||||
let url = canonical();
|
||||
let result = verify_bearer(&url, Some("not!!!base64!!!"));
|
||||
assert!(matches!(result, Err(ref e) if e.contains("base64")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_wrong_method() {
|
||||
// NIP-98 event signed for POST but the iroh-relay handshake is GET.
|
||||
// The bearer must not be accepted with the wrong method.
|
||||
let keys = Keys::generate();
|
||||
let url = canonical();
|
||||
let json = signed_event_json(&keys, &url, "POST");
|
||||
let token = bearer(&json);
|
||||
|
||||
let result = verify_bearer(&url, Some(&token));
|
||||
assert!(
|
||||
matches!(result, Err(ref e) if e.contains("method")),
|
||||
"expected method-mismatch denial, got {result:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_wrong_url() {
|
||||
// Event signed for a DIFFERENT relay URL must not authorize access
|
||||
// to *this* relay. This is the property that breaks if the canonical
|
||||
// helper drifts between signer and verifier.
|
||||
let keys = Keys::generate();
|
||||
let other_url =
|
||||
nip98_canonical_url("https://other-relay.example.com/iroh", IROH_RELAY_PATH).unwrap();
|
||||
let json = signed_event_json(&keys, &other_url, NIP98_METHOD);
|
||||
let token = bearer(&json);
|
||||
|
||||
let result = verify_bearer(&canonical(), Some(&token));
|
||||
assert!(
|
||||
matches!(result, Err(ref e) if e.contains("URL")),
|
||||
"expected URL-mismatch denial, got {result:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_wrong_kind() {
|
||||
let keys = Keys::generate();
|
||||
let url = canonical();
|
||||
// Build a kind:1 (text note) event instead of kind:27235 — should fail.
|
||||
let event = EventBuilder::new(
|
||||
Kind::TextNote,
|
||||
"",
|
||||
vec![
|
||||
Tag::parse(&["u", &url]).unwrap(),
|
||||
Tag::parse(&["method", NIP98_METHOD]).unwrap(),
|
||||
],
|
||||
)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let token = bearer(&serde_json::to_string(&event).unwrap());
|
||||
|
||||
let result = verify_bearer(&url, Some(&token));
|
||||
assert!(
|
||||
matches!(result, Err(ref e) if e.contains("kind")),
|
||||
"expected kind-mismatch denial, got {result:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_stale_timestamp() {
|
||||
let keys = Keys::generate();
|
||||
let url = canonical();
|
||||
let event = EventBuilder::new(
|
||||
Kind::HttpAuth,
|
||||
"",
|
||||
vec![
|
||||
Tag::parse(&["u", &url]).unwrap(),
|
||||
Tag::parse(&["method", NIP98_METHOD]).unwrap(),
|
||||
],
|
||||
)
|
||||
// Two hours in the past — well outside the ±60s NIP-98 tolerance.
|
||||
.custom_created_at(Timestamp::from(Timestamp::now().as_u64() - 7200))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let token = bearer(&serde_json::to_string(&event).unwrap());
|
||||
|
||||
let result = verify_bearer(&url, Some(&token));
|
||||
assert!(
|
||||
matches!(result, Err(ref e) if e.contains("timestamp")),
|
||||
"expected timestamp denial, got {result:?}",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bearer decoding ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn decode_bearer_accepts_standard() {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
let payload = r#"{"hello":"world"}"#;
|
||||
let token = STANDARD.encode(payload);
|
||||
assert_eq!(decode_bearer(&token).as_deref(), Some(payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_bearer_accepts_url_safe_no_pad() {
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
let payload = r#"{"hello":"world"}"#;
|
||||
let token = URL_SAFE_NO_PAD.encode(payload);
|
||||
assert_eq!(decode_bearer(&token).as_deref(), Some(payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_bearer_rejects_garbage() {
|
||||
assert!(decode_bearer("not base64 at all !!!").is_none());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub mod connection;
|
||||
pub mod error;
|
||||
/// WebSocket message handlers for NIP-01 client commands.
|
||||
pub mod handlers;
|
||||
/// Embedded iroh-relay endpoint, gated by Sprout relay membership.
|
||||
pub mod iroh_relay;
|
||||
/// Prometheus metrics: recorder, upkeep, HTTP middleware.
|
||||
pub mod metrics;
|
||||
/// NIP-11 relay information document.
|
||||
|
||||
Reference in New Issue
Block a user