From 26ea9113b64bf74efc8a590bbd9ac677ce4527df Mon Sep 17 00:00:00 2001 From: kajukabla <106099463+kajukabla@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:31:30 -0700 Subject: [PATCH] feat(relay): make NIP-11 name and description configurable The NIP-11 information document hardcoded name and description, so self-hosters could not brand their relay. Add BUZZ_RELAY_NAME and BUZZ_RELAY_DESCRIPTION env vars (trimmed; unset or blank falls back to the existing defaults) and thread them through RelayInfo::build as deployment-global config scalars, preserving the multi-tenant static-input fence's no-enumeration-oracle property. Co-Authored-By: Claude Fable 5 Signed-off-by: kajukabla <106099463+kajukabla@users.noreply.github.com> --- .env.example | 4 ++ crates/buzz-relay/src/config.rs | 66 +++++++++++++++++++++++ crates/buzz-relay/src/nip11.rs | 93 +++++++++++++++++++++++++++----- deploy/compose/.env.example | 6 +++ docs/multi-tenant-conformance.md | 2 +- 5 files changed, 157 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 3dc54856e7..b76ec14842 100644 --- a/.env.example +++ b/.env.example @@ -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> diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index a1691349d6..37ca91f054 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -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 { @@ -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, /// Maximum number of concurrent WebSocket connections. @@ -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()) @@ -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, @@ -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] diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index a8e397dd21..fe341d09c8 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -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 @@ -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, @@ -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, @@ -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, @@ -325,8 +334,15 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option, 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, @@ -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(); @@ -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, @@ -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()); } @@ -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, @@ -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!( @@ -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)); } @@ -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)); } @@ -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)); } @@ -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)); } @@ -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); } } diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 838824c17f..9d9d6f3a6b 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -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 diff --git a/docs/multi-tenant-conformance.md b/docs/multi-tenant-conformance.md index 3cd56066eb..c46c795190 100644 --- a/docs/multi-tenant-conformance.md +++ b/docs/multi-tenant-conformance.md @@ -40,7 +40,7 @@ Conformance obligations: | Surface | Today's observable behavior | Tenant source | Community-global vs operator-global | Required DB/index/RLS scope | Auth/fan-out/search effects | Single-community compatibility check | Open decision/test | |---|---|---|---|---|---|---|---| | Row zero: host binding | A user connects to one relay URL and all state they can observe belongs to that relay. | `resolve_host(connection.host)` before handler entry. | Community-global selector; operator only manages the host map. | `communities(host, id, signing_key, …)`; every scoped table references immutable `community_id`. | All auth, event, REST, media, git, search, pub/sub, and workflow paths consume `TenantContext`; host/token mismatch rejects generically. | One host maps to the default community; no client-visible protocol field changes. | Add model/prose gate that `ctx.community` is derived from host, not supplied by the client. | -| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs, a stable relay signing pubkey when configured, and the community's workspace icon (NIP-WP). | Host-derived community for community-specific facts; the workspace `icon` is pre-fetched as a scalar via `bind_community` (fail-open to absent on unmapped host); no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own `icon`, no field varies by community, and an unmapped host's document carries no `icon`. NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped scalars (like `icon`) are passed in pre-derived. | +| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises the operator-configured relay name/description (`BUZZ_RELAY_NAME`/`BUZZ_RELAY_DESCRIPTION`, deployment-global config scalars), static NIPs, a stable relay signing pubkey when configured, and the community's workspace icon (NIP-WP). | Host-derived community for community-specific facts; the workspace `icon` is pre-fetched as a scalar via `bind_community` (fail-open to absent on unmapped host); no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own `icon`, no field varies by community, and an unmapped host's document carries no `icon`. NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped scalars (like `icon`) are passed in pre-derived. | | API tokens and NIP-98 replay | API/NIP-98 clients authenticate REST/media/git; API tokens may carry scopes and channel IDs; NIP-98 replay uses an in-process seen-set today. | Host-derived community plus token's stamped community; stamps must agree. | Community-global token namespace; operator-global only for deployment health/secrets. | `api_tokens` gains `community_id`; token hash uniqueness and lookup are `(community_id, token_hash)` or the token cryptographically embeds community and lookup verifies both. Channel claims must reference channels in the same community. | Replay seen-set key is `(community_id, event_id)` in shared HA storage or equivalent sticky routing; NIP-98 `u` URL host must match `req.community`. | Existing single-community tokens continue to authorize the same scopes/channels after backfill to default community. | HA gate: Redis/shared seen-set with atomic insert-if-absent and TTL ≥ replay window, or documented single-replica/sticky alternative. | | Relay membership, pubkey allowlist, archived identities | `relay_members`, `pubkey_allowlist`, and `archived_identities` are relay-global gates over pubkeys. | Host-derived community for tenant access; operator context only for platform administration. | Community-global membership/allowlist/archive by default. Operator-global only for explicit platform ops tables that are never tenant-observable. | Add `community_id` to these tables; primary/unique keys become `(community_id, pubkey)` and indexes include `community_id`. | Membership errors remain generic. NIP-OA owner checks test owner membership in the same community. Identity archive requests cannot hide/archive a key in another community. | One default community preserves today's closed/open relay behavior and admin CLI semantics after commands target the default community. | Decide any future operator-global super-admin surface separately; do not reuse tenant membership tables for it. | | Users, profiles, NIP-05, and user search | Kind:0 updates sync a `users` row; NIP-05 handles are unique; `/api/users/search` searches display name/NIP-05/pubkey. | Channel-less events use `req.community`; NIP-05 domain is the connected community host. | Community-global. Same pubkey can have one profile per community; users repost kind:0 in each community they join. | `users` gains `community_id`; keys/uniques are `(community_id, pubkey)`, `(community_id, lower(nip05_handle))`, and `(community_id, okta_user_id)` where applicable. Profile event replacement is scoped by `(community_id, pubkey)`. | Search and batch profile reads include `community_id`; NIP-05 lookup only resolves handles for the requested host/community. No cross-community profile inheritance. | Existing users backfill into the default community; profile APIs and CLI output stay unchanged. | Add tests for same pubkey with different profile/NIP-05 in two communities and for NIP-05 same local part on two hosts. |