From fc48cd8eb6ddec3a1da036783985036d6ce060f1 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 2 Jul 2026 11:08:47 -0600 Subject: [PATCH] feat: per-community workspace icon set by admins, served via NIP-11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a workspace-wide icon that owners/admins set once and every member sees, replacing the initials-only avatars in the workspace rail. Relay: new kind:9033 admin command (validated against relay_members role like 9030-9032) stores the icon as per-community state (communities.icon, additive migration 0003). The icon is served in the standard `icon` field of the NIP-11 relay information document, bound to the community resolved from the request Host; unmapped hosts get an icon-less document (fail-open, no enumeration oracle — conformance test updated to prove the doc varies only by the host's own icon). Icons are small data:image/* URLs (or http(s) URLs), validated and size-capped on ingest. Desktop: admin/owner-gated "Workspace icon" editor in Settings > Relay Access downscales the chosen image to a 128px data URL client-side and publishes 9033. The rail and workspace switcher read each relay's NIP-11 document with one unauthenticated HTTP GET (new fetch_workspace_icon Tauri command) — active and inactive workspaces alike — with a localStorage cache so icons render instantly on boot and offline, falling back to initials. Docs: draft NIP-WP (docs/nips/NIP-WP.md) specifying the kind:9033 write path and the plain NIP-11 read path, including why NIP-86's changerelayicon and NIP-29 group metadata were not used; cross-referenced from NOSTR.md. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- NOSTR.md | 8 + crates/buzz-core/src/kind.rs | 11 +- crates/buzz-db/src/lib.rs | 43 ++++++ crates/buzz-db/src/migration.rs | 11 +- crates/buzz-relay/src/handlers/ingest.rs | 9 +- crates/buzz-relay/src/handlers/relay_admin.rs | 113 ++++++++++++++- crates/buzz-relay/src/nip11.rs | 114 +++++++++++++-- crates/buzz-relay/src/router.rs | 30 ++-- .../tests/conformance_multitenant.rs | 57 +++++--- desktop/src-tauri/src/commands/workspace.rs | 39 ++++- desktop/src-tauri/src/lib.rs | 1 + .../ui/RelayMembersSettingsCard.tsx | 2 + .../src/features/sidebar/ui/WorkspaceRail.tsx | 19 ++- .../features/workspaces/lib/downscaleIcon.ts | 37 +++++ .../ui/WorkspaceIconSettingsCard.tsx | 137 ++++++++++++++++++ .../workspaces/ui/WorkspaceSwitcher.tsx | 28 +++- .../features/workspaces/useWorkspaceIcons.ts | 68 +++++++++ .../features/workspaces/workspaceIconCache.ts | 41 ++++++ desktop/src/shared/api/workspaceProfile.ts | 51 +++++++ docs/multi-tenant-conformance.md | 2 +- docs/nips/NIP-WP.md | 94 ++++++++++++ migrations/0003_community_icon.sql | 12 ++ 22 files changed, 858 insertions(+), 69 deletions(-) create mode 100644 desktop/src/features/workspaces/lib/downscaleIcon.ts create mode 100644 desktop/src/features/workspaces/ui/WorkspaceIconSettingsCard.tsx create mode 100644 desktop/src/features/workspaces/useWorkspaceIcons.ts create mode 100644 desktop/src/features/workspaces/workspaceIconCache.ts create mode 100644 desktop/src/shared/api/workspaceProfile.ts create mode 100644 docs/nips/NIP-WP.md create mode 100644 migrations/0003_community_icon.sql diff --git a/NOSTR.md b/NOSTR.md index fbf521a8d..59df31b99 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -262,6 +262,7 @@ the sender to be authenticated (NIP-42) as the relay owner or an admin. | 9030 | Add member | `["p", ""]`, optional `["role", "member\|admin"]` | | 9031 | Remove member | `["p", ""]`, optional `["role", "member\|admin"]` | | 9032 | Change role | `["p", ""]`, `["role", "member\|admin"]` | +| 9033 | Set workspace profile (icon) | `["icon", ""]` (empty clears) | Example using `nak`: @@ -295,6 +296,13 @@ After each add/remove/role-change, the relay publishes a kind:13534 membership l nak req -k 13534 --auth --sec ws://localhost:3000 ``` +A kind:9033 command similarly makes the relay store the workspace icon (per +community) and serve it in the standard NIP-11 `icon` field of its relay +information document. Clients render it in the workspace rail/switcher; anyone +can read it (`curl -H 'Accept: application/nostr+json' http://localhost:3000`), +but only admins/owners can set it. Full spec: +[docs/nips/NIP-WP.md](docs/nips/NIP-WP.md). + ### Known Limitations 1. **CLI intentionally does not emit kind 8000/8001 deltas** — `publish_nip43_delta` is diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index f2e918424..6b86b1c1f 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -187,6 +187,8 @@ pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030; pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031; /// NIP-43: Change the role of an existing relay member. pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032; +/// Buzz: Set the workspace profile (icon). Admin/owner-signed command. +pub const RELAY_ADMIN_SET_WORKSPACE_PROFILE: u32 = 9033; // NIP-43 relay membership announcement events (relay-signed) /// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention). pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534; @@ -455,6 +457,7 @@ pub const ALL_KINDS: &[u32] = &[ RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, KIND_NIP43_MEMBERSHIP_LIST, KIND_NIP43_MEMBER_ADDED, KIND_NIP43_MEMBER_REMOVED, @@ -568,11 +571,15 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool { kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED } -/// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032). +/// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032) +/// or the Buzz workspace-profile admin command (9033). pub const fn is_relay_admin_kind(kind: u32) -> bool { matches!( kind, - RELAY_ADMIN_ADD_MEMBER | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE + RELAY_ADMIN_ADD_MEMBER + | RELAY_ADMIN_REMOVE_MEMBER + | RELAY_ADMIN_CHANGE_ROLE + | RELAY_ADMIN_SET_WORKSPACE_PROFILE ) } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 69150aaad..29701d575 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -321,6 +321,49 @@ impl Db { .transpose() } + /// Returns the community's workspace icon (NIP-11 `icon`), if set. + /// + /// Set by relay admins/owners via the kind:9033 command; the value is + /// validated and size-capped at that write path. + pub async fn get_community_icon(&self, community_id: CommunityId) -> Result> { + let row = sqlx::query( + r#" + SELECT icon + FROM communities + WHERE id = $1 + "#, + ) + .bind(community_id.as_uuid()) + .fetch_optional(&self.pool) + .await?; + + Ok(row + .map(|row| row.try_get::, _>("icon")) + .transpose()? + .flatten() + .filter(|icon| !icon.is_empty())) + } + + /// Sets or clears (`None`) the community's workspace icon. + pub async fn set_community_icon( + &self, + community_id: CommunityId, + icon: Option<&str>, + ) -> Result<()> { + sqlx::query( + r#" + UPDATE communities + SET icon = $2 + WHERE id = $1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(icon) + .execute(&self.pool) + .await?; + Ok(()) + } + /// Ensure a configured community host exists and return its row. /// /// This is the startup/config seeding path for N=1 deployments. Migrations diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 74d85aa90..7caf822c2 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -471,7 +471,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 2); + assert_eq!(migrations.len(), 3); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -506,6 +506,15 @@ mod tests { .as_str() .contains("CREATE TABLE git_repo_names")); assert!(!migrations[0].sql.as_str().contains("git_repo_names")); + + // Same additive-migration rule for the per-community workspace icon + // (NIP-11 `icon`): its own version, never folded into 0001. + assert_eq!(migrations[2].version, 3); + assert!(migrations[2] + .sql + .as_str() + .contains("ALTER TABLE communities ADD COLUMN icon")); + assert!(!migrations[0].sql.as_str().contains("icon")); } #[test] diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5c0c188a7..3b074de21 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -32,7 +32,7 @@ use buzz_core::kind::{ KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, - RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -188,10 +188,12 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { Ok(Scope::AdminChannels) } - // NIP-43: relay membership admin commands (9030–9032) + // NIP-43: relay membership admin commands (9030–9032) + Buzz + // workspace-profile command (9033) k if k == RELAY_ADMIN_ADD_MEMBER || k == RELAY_ADMIN_REMOVE_MEMBER - || k == RELAY_ADMIN_CHANGE_ROLE => + || k == RELAY_ADMIN_CHANGE_ROLE + || k == RELAY_ADMIN_SET_WORKSPACE_PROFILE => { Ok(Scope::AdminUsers) } @@ -367,6 +369,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | RELAY_ADMIN_ADD_MEMBER | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE + | RELAY_ADMIN_SET_WORKSPACE_PROFILE | KIND_NIP43_LEAVE_REQUEST // NIP-IA: identity archive/unarchive requests drive relay-global // archive state (8002/8003/13535) and are audited as global request diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index fe44e74c7..840d9dcfe 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -10,13 +10,17 @@ //! | 9030 | Add member | admin or owner | //! | 9031 | Remove member | admin or owner | //! | 9032 | Change role | owner only | +//! | 9033 | Set workspace profile (icon) | admin or owner | use std::sync::Arc; use nostr::Event; use tracing::{info, warn}; -use buzz_core::kind::{RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER}; +use buzz_core::kind::{ + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, +}; use buzz_core::tenant::TenantContext; use buzz_db::relay_members::RemoveResult; @@ -52,7 +56,45 @@ fn extract_tag_value(event: &Event, name: &str) -> Option { None } -/// Validate and execute a relay admin command (kinds 9030–9032). +/// Maximum accepted workspace icon https URL length. +const MAX_WORKSPACE_ICON_URL_LEN: usize = 2048; + +/// Maximum accepted workspace icon data-URL length (~96 KB of base64 ≈ 72 KB +/// image — generous for a 128px icon). +const MAX_WORKSPACE_ICON_DATA_URL_LEN: usize = 98_304; + +/// Validate a workspace icon: empty (clear), an http(s) URL, or an inline +/// `data:image/*` URL (what the desktop publishes — it renders across +/// workspaces without cross-relay media fetches). +fn validate_workspace_icon(icon: &str) -> Result<(), String> { + if icon.is_empty() { + return Ok(()); + } + if icon.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("icon contains invalid characters".to_string()); + } + if icon.starts_with("data:image/") { + if icon.len() > MAX_WORKSPACE_ICON_DATA_URL_LEN { + return Err(format!( + "icon data URL too long: {} bytes (max {MAX_WORKSPACE_ICON_DATA_URL_LEN})", + icon.len() + )); + } + return Ok(()); + } + if !icon.starts_with("https://") && !icon.starts_with("http://") { + return Err("icon must be an http(s) URL or data:image/* URL".to_string()); + } + if icon.len() > MAX_WORKSPACE_ICON_URL_LEN { + return Err(format!( + "icon URL too long: {} bytes (max {MAX_WORKSPACE_ICON_URL_LEN})", + icon.len() + )); + } + Ok(()) +} + +/// Validate and execute a relay admin command (kinds 9030–9033). /// /// The handler: /// 1. Extracts the target pubkey from the `["p", ...]` tag. @@ -88,10 +130,6 @@ pub async fn handle_relay_admin_event( } } - let target_hex = extract_p_tag_hex(event) - .ok_or_else(|| "missing or invalid p tag".to_string())? - .to_ascii_lowercase(); - let sender_member = state .db .get_relay_member(tenant.community(), &sender_hex) @@ -103,6 +141,34 @@ pub async fn handle_relay_admin_event( .map(|m| m.role.as_str()) .unwrap_or(""); + // kind:9033 — Set workspace profile (icon). Handled before p-tag + // extraction: it targets the relay itself, not a member pubkey. + if kind == RELAY_ADMIN_SET_WORKSPACE_PROFILE { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".to_string()); + } + + // Empty or missing icon tag clears the workspace icon. + let icon = extract_tag_value(event, "icon").unwrap_or_default(); + validate_workspace_icon(&icon)?; + + state + .db + .set_community_icon( + tenant.community(), + (!icon.is_empty()).then_some(icon.as_str()), + ) + .await + .map_err(|e| format!("failed to store workspace icon: {e}"))?; + + info!(sender = %sender_hex, icon_len = icon.len(), "workspace profile updated"); + return Ok(()); + } + + let target_hex = extract_p_tag_hex(event) + .ok_or_else(|| "missing or invalid p tag".to_string())? + .to_ascii_lowercase(); + match kind { // kind:9030 — Add relay member k if k == RELAY_ADMIN_ADD_MEMBER => { @@ -364,4 +430,39 @@ mod tests { let event = make_test_event(9030, vec![vec!["role", "admin"]]); assert_eq!(extract_tag_value(&event, "p"), None); } + + #[test] + fn workspace_icon_empty_ok() { + assert!(validate_workspace_icon("").is_ok()); + } + + #[test] + fn workspace_icon_https_ok() { + assert!(validate_workspace_icon("https://example.com/icon.png").is_ok()); + } + + #[test] + fn workspace_icon_data_url_ok() { + assert!(validate_workspace_icon("data:image/webp;base64,UklGRg==").is_ok()); + } + + #[test] + fn workspace_icon_rejects_non_url() { + assert!(validate_workspace_icon("javascript:alert(1)").is_err()); + assert!(validate_workspace_icon("data:text/html;base64,PGI+").is_err()); + } + + #[test] + fn workspace_icon_rejects_whitespace_and_control() { + assert!(validate_workspace_icon("https://example.com/a b.png").is_err()); + assert!(validate_workspace_icon("https://example.com/a\nb.png").is_err()); + } + + #[test] + fn workspace_icon_rejects_oversized() { + let long_url = format!("https://example.com/{}.png", "a".repeat(2048)); + assert!(validate_workspace_icon(&long_url).is_err()); + let long_data = format!("data:image/png;base64,{}", "A".repeat(98_304)); + assert!(validate_workspace_icon(&long_data).is_err()); + } } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index ca859e033..03e0065ef 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -27,6 +27,10 @@ pub struct RelayInfo { pub name: String, /// Human-readable relay description. pub description: String, + /// Workspace icon URL (NIP-11 `icon`), per-community, set by relay + /// admins/owners via the kind:9033 command. Omitted when no icon is set. + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, /// Relay operator's public key (hex), if published. pub pubkey: Option, /// Contact address for the relay operator. @@ -114,6 +118,10 @@ impl RelayInfo { /// unconditionally) requires clients to verify those events against /// `self`. Pass `Some` whenever the relay has a stable signing key. /// + /// `icon` is the community's workspace icon (see + /// [`workspace_icon_for_host`]) — a host-scoped scalar, pre-fetched by + /// the caller so `build` itself stays static-input. + /// /// `advertise_nip43` controls whether NIP-43 (relay membership) is added /// to `supported_nips`. Set `true` only when the relay actually emits and /// gates on NIP-43 events — i.e. has a stable key AND enforces @@ -121,6 +129,7 @@ impl RelayInfo { /// programmer error to advertise NIP-43 without a `relay_self`. pub fn build( relay_self: Option<&str>, + icon: Option<&str>, advertise_nip43: bool, max_message_length: usize, ) -> Self { @@ -137,6 +146,7 @@ impl RelayInfo { Self { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), + icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()), pubkey: None, contact: None, supported_nips, @@ -152,13 +162,51 @@ impl RelayInfo { /// Axum handler that returns the NIP-11 relay information document as JSON. pub async fn relay_info_handler( axum::extract::State(state): axum::extract::State>, + headers: axum::http::HeaderMap, ) -> axum::response::Json { - let (relay_self, advertise_nip43) = nip11_facts(&state); - axum::response::Json(RelayInfo::build( + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + axum::response::Json(nip11_document(&state, raw_host).await) +} + +/// Builds the served NIP-11 document for a request arriving on `raw_host`. +/// +/// Centralised so the content-negotiated root handler and the dedicated +/// `/info` endpoint can't drift apart. Every input to `RelayInfo::build` +/// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the +/// host-scoped workspace icon. +pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo { + let (relay_self, advertise_nip43) = nip11_facts(state); + let icon = workspace_icon_for_host(state, raw_host).await; + RelayInfo::build( relay_self.as_deref(), + icon.as_deref(), advertise_nip43, state.config.max_frame_bytes, - )) + ) +} + +/// Fetches the workspace icon for the community bound to `raw_host`, as the +/// host-scoped scalar consumed by [`RelayInfo::build`]. +/// +/// The icon is per-community state (`communities.icon`, set by relay +/// admins/owners via the kind:9033 command) served in the standard NIP-11 +/// `icon` field. The lookup is scoped through +/// [`crate::tenant::bind_community`] — never an unscoped query. Fails open to +/// `None` (no `icon` field): NIP-11 is intentionally served to unmapped hosts +/// too, and an icon lookup failure must not break that. +async fn workspace_icon_for_host(state: &crate::state::AppState, raw_host: &str) -> Option { + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .ok()?; + state + .db + .get_community_icon(tenant.community()) + .await + .ok() + .flatten() } /// Derives the two NIP-11 facts that depend on runtime config: @@ -185,9 +233,13 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option, bo /// /// The conformance obligation: `RelayInfo::build` "must not grow unscoped /// DB/search/audit inputs", so an unauthenticated NIP-11 read can never become -/// an enumeration oracle for other communities. Today `build` takes only static +/// an enumeration oracle for *other* communities. `build` takes only static /// and scalar inputs — the per-deployment facts arrive pre-derived through -/// [`nip11_facts`], which reads config and the relay keypair only. +/// [`nip11_facts`] (config + relay keypair), and the one host-scoped fact +/// (the workspace `icon`) arrives as a scalar from +/// [`workspace_icon_for_host`], whose DB lookup is scoped through +/// [`crate::tenant::bind_community`] and can therefore only ever surface the +/// requesting host's own community state. /// /// This const binds `RelayInfo::build` to its **exact** allowed signature. The /// moment someone adds a `&Db`, `&AppState`, a search handle, an audit handle, @@ -196,8 +248,12 @@ 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. -const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(Option<&str>, bool, usize) -> RelayInfo = - RelayInfo::build; +const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( + Option<&str>, + Option<&str>, + bool, + usize, +) -> RelayInfo = RelayInfo::build; #[cfg(test)] mod tests { @@ -227,10 +283,42 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.software, "https://github.com/block/buzz"); } + /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard + /// `icon` field; no icon (or a cleared, empty icon) omits the field + /// 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( + None, + Some("data:image/webp;base64,UklGRg=="), + false, + DEFAULT_MAX_FRAME_BYTES, + ); + assert_eq!( + info.icon.as_deref(), + Some("data:image/webp;base64,UklGRg==") + ); + let json = serde_json::to_value(&info).expect("serialize"); + assert_eq!( + json.get("icon").and_then(|v| v.as_str()), + Some("data:image/webp;base64,UklGRg==") + ); + + for icon in [None, Some("")] { + let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES); + assert!(info.icon.is_none()); + let json = serde_json::to_value(&info).expect("serialize"); + assert!( + json.get("icon").is_none(), + "unset/cleared icon must omit the `icon` field, not serialize null/empty" + ); + } + } + #[test] fn auth_required_is_advertised_true() { // REQ, EVENT, and COUNT all unconditionally require @@ -241,7 +329,7 @@ mod tests { #[test] fn max_message_length_uses_configured_frame_limit() { - let info = RelayInfo::build(None, false, 262_144); + let info = RelayInfo::build(None, None, false, 262_144); let limitation = info.limitation.expect("limitation"); assert_eq!(limitation.max_message_length, Some(262_144)); } @@ -272,7 +360,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, false, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -285,7 +373,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), false, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -294,7 +382,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), true, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -305,6 +393,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, true, DEFAULT_MAX_FRAME_BYTES); + let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 442531bcd..fc9e1ec38 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -21,7 +21,7 @@ use crate::api; use crate::audio; use crate::connection::handle_connection; use crate::metrics::track_metrics; -use crate::nip11::{nip11_facts, relay_info_handler, RelayInfo}; +use crate::nip11::{nip11_document, relay_info_handler}; use crate::state::AppState; /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. @@ -149,27 +149,22 @@ async fn nip11_or_ws_handler( .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let (relay_self, advertise_nip43) = nip11_facts(&state); + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); if accept.contains("application/nostr+json") { - let info = RelayInfo::build( - relay_self.as_deref(), - advertise_nip43, - state.config.max_frame_bytes, - ); - return Json(info).into_response(); + return Json(nip11_document(&state, raw_host).await).into_response(); } // Row zero: bind the connection to its community from the request host // BEFORE the WebSocket upgrade, so no frame is ever read on an unbound // connection. The host is the authoritative selector; an unmapped host or a // lookup failure fails closed with a generic rejection — never a default - // tenant. NIP-11 above is intentionally host-agnostic (static facts only), - // so it is served before binding and cannot leak which hosts are mapped. - let raw_host = headers - .get(axum::http::header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); + // tenant. NIP-11 above is served before binding and stays fail-open: an + // unmapped host still gets the document (with host-scoped fields like + // `icon` simply absent), so the doc cannot leak which hosts are mapped. let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { Ok(ctx) => ctx, Err(_) => { @@ -199,12 +194,7 @@ async fn nip11_or_ws_handler( } } // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. - let info = RelayInfo::build( - relay_self.as_deref(), - advertise_nip43, - state.config.max_frame_bytes, - ); - Json(info).into_response() + Json(nip11_document(&state, raw_host).await).into_response() } } } diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 423aa42df..15002142e 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -454,14 +454,17 @@ mod nip11_relay_info { /// input; this test proves the *observable wire behavior* — that the served /// document carries nothing that distinguishes one community from another. /// - /// Because `RelayInfo::build` is genuinely static-input today, host A's and - /// host B's NIP-11 bodies are byte-identical, and that identity *is* the - /// proof: no field varies by community, so an unauthenticated reader cannot - /// use the document to probe whether (or how) community B is configured. - /// The moment a per-community value leaks into the doc, the two bodies - /// diverge and this assertion fails — that is the mutate-bite this row - /// guards (seed a community-distinguishing field into the served doc → the - /// A≡B assertion goes red). + /// One field is deliberately host-scoped: `icon` (the NIP-WP workspace + /// icon, per-community state served in the standard NIP-11 field, fetched + /// through `bind_community` so it can only ever be the requesting host's + /// own community state — intentionally public presentation, exactly what + /// upstream NIP-11 `icon` is). So the + /// oracle proof is: the documents are identical *modulo each community's + /// own `icon`*, and an unmapped host's document carries no `icon` at all. + /// The moment any *other* per-community value leaks into the doc, the + /// icon-stripped bodies diverge and this assertion fails — that is the + /// mutate-bite this row guards (seed a community-distinguishing field into + /// the served doc → the A≡B assertion goes red). #[tokio::test] #[ignore] async fn nip11_is_not_a_cross_community_enumeration_oracle() { @@ -481,9 +484,9 @@ mod nip11_relay_info { // Both bodies must be valid NIP-11 JSON — a relay-info object, not an // error page or a host echo. - let json_a: serde_json::Value = + let mut json_a: serde_json::Value = serde_json::from_str(&body_a).expect("host A NIP-11 is valid JSON"); - let json_b: serde_json::Value = + let mut json_b: serde_json::Value = serde_json::from_str(&body_b).expect("host B NIP-11 is valid JSON"); assert!( json_a.get("supported_nips").is_some(), @@ -494,14 +497,23 @@ mod nip11_relay_info { "host B NIP-11 must be a relay-info document (has supported_nips)" ); - // The enumeration-oracle obligation: no field of the served document - // varies by community. Identical bodies are the proof that the doc - // cannot be used to distinguish or probe another tenant. + // `icon` is the one deliberately host-scoped field (the NIP-WP + // workspace icon; each host sees only its own community's icon). + // Strip it before the equality proof — every OTHER field must still + // be community-agnostic. + json_a.as_object_mut().map(|o| o.remove("icon")); + json_b.as_object_mut().map(|o| o.remove("icon")); + + // The enumeration-oracle obligation: no other field of the served + // document varies by community. Identical icon-stripped bodies are the + // proof that the doc cannot be used to distinguish or probe another + // tenant. assert_eq!( json_a, json_b, - "NIP-11 from host A and host B must be identical: any community-\ - distinguishing field would make the unauthenticated relay-info \ - document an enumeration oracle for other tenants" + "NIP-11 from host A and host B must be identical apart from each \ + community's own `icon`: any other community-distinguishing field \ + would make the unauthenticated relay-info document an enumeration \ + oracle for other tenants" ); // An *unmapped* host must get the SAME document too — not a 404. NIP-11 @@ -523,11 +535,18 @@ mod nip11_relay_info { ); let json_unknown: serde_json::Value = serde_json::from_str(&body_unknown).expect("unmapped-host NIP-11 is valid JSON"); + assert!( + json_unknown.get("icon").is_none(), + "an unmapped host binds to no community, so its NIP-11 document must \ + carry no `icon` — leaking any community's icon to an unmapped host \ + would cross the tenant boundary" + ); assert_eq!( json_a, json_unknown, - "NIP-11 served to an unmapped host must be byte-identical to a mapped \ - host's document: the relay-info doc carries no host-derived field, \ - so it cannot reveal whether a given host is configured" + "NIP-11 served to an unmapped host must match a mapped host's \ + icon-stripped document: apart from the host's own `icon`, the \ + relay-info doc carries no host-derived field, so it cannot reveal \ + whether a given host is configured" ); } } diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 7c0e4b832..e0ab14a43 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,5 +1,5 @@ use nostr::Keys; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use crate::app_state::AppState; @@ -9,6 +9,43 @@ use crate::managed_agents::{ }; use crate::relay; +#[derive(Deserialize)] +struct RelayInfoIcon { + #[serde(default)] + icon: Option, +} + +/// Fetch a relay's workspace icon from its NIP-11 relay information document. +/// +/// Works for any workspace (active or not) with a plain unauthenticated HTTP +/// GET — no WebSocket session needed. Returns `None` when the relay has no +/// icon set, is unreachable, or serves a malformed document: the rail falls +/// back to initials in all three cases. +#[tauri::command] +pub async fn fetch_workspace_icon( + relay_url: String, + state: State<'_, AppState>, +) -> Result, String> { + let http_url = relay::relay_http_base_url(&relay_url); + let Ok(response) = state + .http_client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + else { + return Ok(None); + }; + if !response.status().is_success() { + return Ok(None); + } + let doc = response + .json::() + .await + .unwrap_or(RelayInfoIcon { icon: None }); + Ok(doc.icon.filter(|icon| !icon.is_empty())) +} + #[derive(Serialize)] pub struct ActiveWorkspaceInfo { relay_url: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f811eba26..53b41c256 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -607,6 +607,7 @@ pub fn run() { apply_workspace, validate_repos_dir, get_active_workspace, + fetch_workspace_icon, set_prevent_sleep_active, get_agent_memory, relay_reconnect_hook, diff --git a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx index 49efc2bdc..41fa1a111 100644 --- a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx +++ b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx @@ -37,6 +37,7 @@ import { } from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { WorkspaceIconSettingsCard } from "@/features/workspaces/ui/WorkspaceIconSettingsCard"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; type AssignableRelayRole = Exclude; @@ -365,6 +366,7 @@ export function RelayMembersSettingsCard({ />
+