Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ TYPESENSE_URL=http://localhost:8108
BUZZ_BIND_ADDR=0.0.0.0:3000
# Public WebSocket URL — used in NIP-42 auth challenges
RELAY_URL=ws://localhost:3000
# Relay identity advertised in the NIP-11 information document. Self-hosters
# set these to brand their relay; defaults shown below.
# BUZZ_RELAY_NAME=Buzz Relay
# BUZZ_RELAY_DESCRIPTION=Buzz — private team communication relay
# Stable relay signing key. Set this in dev if you want REST-created forum posts
# to keep resolving to the original author across relay restarts.
# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key>
Expand Down
66 changes: 66 additions & 0 deletions crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ use tracing::warn;
/// NIP-44 encryption overhead.
pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024;

/// Default NIP-11 relay name, used when `BUZZ_RELAY_NAME` is unset.
pub const DEFAULT_RELAY_NAME: &str = "Buzz Relay";

/// Default NIP-11 relay description, used when `BUZZ_RELAY_DESCRIPTION` is unset.
pub const DEFAULT_RELAY_DESCRIPTION: &str = "Buzz — private team communication relay";

/// Errors that can occur while loading relay configuration.
#[derive(Debug, Error)]
pub enum ConfigError {
Expand Down Expand Up @@ -74,6 +80,13 @@ pub struct Config {
pub db_pool_size: u32,
/// Public WebSocket URL of this relay, advertised in NIP-11.
pub relay_url: String,
/// Human-readable relay name advertised in the NIP-11 `name` field.
/// Set via `BUZZ_RELAY_NAME`; defaults to [`DEFAULT_RELAY_NAME`].
pub relay_name: String,
/// Human-readable relay description advertised in the NIP-11 `description`
/// field. Set via `BUZZ_RELAY_DESCRIPTION`; defaults to
/// [`DEFAULT_RELAY_DESCRIPTION`].
pub relay_description: String,
/// Public WebSocket URL of the dedicated device-pairing relay, when configured.
pub pairing_relay_url: Option<String>,
/// Maximum number of concurrent WebSocket connections.
Expand Down Expand Up @@ -441,6 +454,18 @@ impl Config {
let relay_url =
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());

let relay_name = std::env::var("BUZZ_RELAY_NAME")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_RELAY_NAME.to_string());

let relay_description = std::env::var("BUZZ_RELAY_DESCRIPTION")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_RELAY_DESCRIPTION.to_string());

let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL")
.ok()
.map(|value| value.trim().to_string())
Expand Down Expand Up @@ -891,6 +916,8 @@ impl Config {
redis_pool_size,
db_pool_size,
relay_url,
relay_name,
relay_description,
pairing_relay_url,
max_connections,
max_concurrent_handlers,
Expand Down Expand Up @@ -998,6 +1025,45 @@ mod tests {
config.huddle_audio_available,
"huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"
);
assert_eq!(config.relay_name, DEFAULT_RELAY_NAME);
assert_eq!(config.relay_description, DEFAULT_RELAY_DESCRIPTION);
}

#[test]
fn relay_name_and_description_env_override_and_blank_fallback() {
let _guard = ENV_MUTEX.lock().unwrap();
let previous_name = std::env::var_os("BUZZ_RELAY_NAME");
let previous_description = std::env::var_os("BUZZ_RELAY_DESCRIPTION");

std::env::set_var("BUZZ_RELAY_NAME", " Acme Chat ");
std::env::set_var("BUZZ_RELAY_DESCRIPTION", "Acme's private relay");
let overridden = Config::from_env().expect("config");

std::env::set_var("BUZZ_RELAY_NAME", " ");
std::env::set_var("BUZZ_RELAY_DESCRIPTION", "");
let blank = Config::from_env().expect("config");

for (name, value) in [
("BUZZ_RELAY_NAME", previous_name),
("BUZZ_RELAY_DESCRIPTION", previous_description),
] {
if let Some(value) = value {
std::env::set_var(name, value);
} else {
std::env::remove_var(name);
}
}

assert_eq!(overridden.relay_name, "Acme Chat", "value must be trimmed");
assert_eq!(overridden.relay_description, "Acme's private relay");
assert_eq!(
blank.relay_name, DEFAULT_RELAY_NAME,
"blank value must fall back to the default"
);
assert_eq!(
blank.relay_description, DEFAULT_RELAY_DESCRIPTION,
"blank value must fall back to the default"
);
}

#[test]
Expand Down
93 changes: 80 additions & 13 deletions crates/buzz-relay/src/nip11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};

#[cfg(test)]
use crate::config::DEFAULT_MAX_FRAME_BYTES;
use crate::config::{DEFAULT_MAX_FRAME_BYTES, DEFAULT_RELAY_DESCRIPTION, DEFAULT_RELAY_NAME};

/// NIPs unconditionally supported by this relay, advertised in the NIP-11
/// document. Kept as a module-level constant so tests can verify it without
Expand Down Expand Up @@ -133,7 +133,14 @@ impl RelayInfo {
/// gates on NIP-43 events — i.e. has a stable key AND enforces
/// membership. NIP-43 events are verified against `self`, so it is a
/// programmer error to advertise NIP-43 without a `relay_self`.
///
/// `name` and `description` are the operator-configured relay identity
/// (`BUZZ_RELAY_NAME` / `BUZZ_RELAY_DESCRIPTION`, defaults applied at
/// config load) — deployment-global scalars, identical for every
/// community on the deployment.
pub fn build(
name: &str,
description: &str,
relay_self: Option<&str>,
icon: Option<&str>,
advertise_nip43: bool,
Expand All @@ -151,8 +158,8 @@ impl RelayInfo {
}

Self {
name: "Buzz Relay".to_string(),
description: "Buzz — private team communication relay".to_string(),
name: name.to_string(),
description: description.to_string(),
icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()),
pubkey: None,
contact: None,
Expand Down Expand Up @@ -236,6 +243,8 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st
let (relay_self, advertise_nip43) = nip11_facts(state);
let icon = workspace_icon_for_host(state, raw_host).await;
let mut info = RelayInfo::build(
&state.config.relay_name,
&state.config.relay_description,
relay_self.as_deref(),
icon.as_deref(),
advertise_nip43,
Expand Down Expand Up @@ -325,8 +334,15 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bo
/// hard build break, the same way a deny-lint would. If you must change this
/// signature, you are changing the conformance contract: update the conformance
/// doc and prove the new input is host-scoped, not unscoped, first.
///
/// The leading `name`/`description` scalars are deployment-global operator
/// config (`BUZZ_RELAY_NAME` / `BUZZ_RELAY_DESCRIPTION`), pre-resolved at
/// config load — identical for every community, so they cannot become an
/// enumeration oracle.
#[allow(clippy::type_complexity)]
const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
&str,
&str,
Option<&str>,
Option<&str>,
bool,
Expand All @@ -338,6 +354,57 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
mod tests {
use super::*;

/// `RelayInfo::build` with the default relay name/description — most
/// tests here exercise other fields and don't care about identity.
fn build_info(
relay_self: Option<&str>,
icon: Option<&str>,
advertise_nip43: bool,
max_message_length: usize,
pairing_relay_url: Option<&str>,
) -> RelayInfo {
RelayInfo::build(
DEFAULT_RELAY_NAME,
DEFAULT_RELAY_DESCRIPTION,
relay_self,
icon,
advertise_nip43,
max_message_length,
pairing_relay_url,
)
}

#[test]
fn name_and_description_default_to_buzz_branding() {
let info = build_info(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
assert_eq!(info.name, "Buzz Relay");
assert_eq!(info.description, "Buzz — private team communication relay");
}

/// Self-hosters brand their relay via `BUZZ_RELAY_NAME` /
/// `BUZZ_RELAY_DESCRIPTION` — the configured values must be served
/// verbatim in the NIP-11 `name`/`description` fields.
#[test]
fn configured_name_and_description_are_served() {
let info = RelayInfo::build(
"Acme Chat",
"Acme's private relay",
None,
None,
false,
DEFAULT_MAX_FRAME_BYTES,
None,
);
assert_eq!(info.name, "Acme Chat");
assert_eq!(info.description, "Acme's private relay");
let json = serde_json::to_value(&info).expect("serialize");
assert_eq!(json.get("name").and_then(|v| v.as_str()), Some("Acme Chat"));
assert_eq!(
json.get("description").and_then(|v| v.as_str()),
Some("Acme's private relay")
);
}

#[test]
fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() {
let keys = nostr::Keys::generate();
Expand Down Expand Up @@ -386,13 +453,13 @@ mod tests {

#[test]
fn build_advertises_buzz_repository_url() {
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = build_info(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
assert_eq!(info.software, "https://github.com/block/buzz");
}

#[test]
fn configured_pairing_relay_is_advertised_and_unset_value_is_omitted() {
let info = RelayInfo::build(
let info = build_info(
None,
None,
false,
Expand All @@ -406,7 +473,7 @@ mod tests {
Some("wss://pairing.buzz.xyz")
);

let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = build_info(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let json = serde_json::to_value(&info).expect("serialize");
assert!(json.get("pairing_relay_url").is_none());
}
Expand All @@ -416,7 +483,7 @@ mod tests {
/// entirely so the JSON matches pre-icon documents byte-for-byte.
#[test]
fn icon_is_mirrored_and_empty_or_absent_is_omitted() {
let info = RelayInfo::build(
let info = build_info(
None,
Some("data:image/webp;base64,UklGRg=="),
false,
Expand All @@ -434,7 +501,7 @@ mod tests {
);

for icon in [None, Some("")] {
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = build_info(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None);
assert!(info.icon.is_none());
let json = serde_json::to_value(&info).expect("serialize");
assert!(
Expand All @@ -454,7 +521,7 @@ mod tests {

#[test]
fn max_message_length_uses_configured_frame_limit() {
let info = RelayInfo::build(None, None, false, 262_144, None);
let info = build_info(None, None, false, 262_144, None);
let limitation = info.limitation.expect("limitation");
assert_eq!(limitation.max_message_length, Some(262_144));
}
Expand Down Expand Up @@ -485,7 +552,7 @@ mod tests {
/// Open relay, ephemeral key — both `self` and NIP-43 are absent.
#[test]
fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = build_info(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
assert!(info.relay_self.is_none());
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
Expand All @@ -498,7 +565,7 @@ mod tests {
#[test]
fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = build_info(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None);
assert_eq!(info.relay_self.as_deref(), Some(pk));
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
Expand All @@ -507,7 +574,7 @@ mod tests {
#[test]
fn build_membership_relay_advertises_self_and_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None);
let info = build_info(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None);
assert_eq!(info.relay_self.as_deref(), Some(pk));
assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
Expand All @@ -518,6 +585,6 @@ mod tests {
#[test]
#[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
fn build_nip43_without_self_panics_in_debug() {
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None);
let _ = build_info(None, None, true, DEFAULT_MAX_FRAME_BYTES, None);
}
}
6 changes: 6 additions & 0 deletions deploy/compose/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media
BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com
BUZZ_CORS_ORIGINS=https://buzz.example.com

# Relay identity advertised in the NIP-11 information document (name and
# description shown by Nostr clients). Optional; defaults to "Buzz Relay" /
# "Buzz — private team communication relay" when unset.
BUZZ_RELAY_NAME=My Team
BUZZ_RELAY_DESCRIPTION=Private team communication relay for My Team

# Production defaults. Closed relay mode requires RELAY_OWNER_PUBKEY and a stable relay key.
BUZZ_REQUIRE_AUTH_TOKEN=true
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
Expand Down
Loading