feat: per-community workspace icon set by admins, served via NIP-11 (#1463)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-02 12:58:42 -06:00
committed by GitHub
parent 01b92faa15
commit 5bfd5ca270
22 changed files with 858 additions and 69 deletions

View File

@@ -262,6 +262,7 @@ the sender to be authenticated (NIP-42) as the relay owner or an admin.
| 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` | | 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
| 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` | | 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
| 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin"]` | | 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin"]` |
| 9033 | Set workspace profile (icon) | `["icon", "<https-url or data:image/* URL>"]` (empty clears) |
Example using `nak`: 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 <privkey> ws://localhost:3000 nak req -k 13534 --auth --sec <privkey> 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 ### Known Limitations
1. **CLI intentionally does not emit kind 8000/8001 deltas** — `publish_nip43_delta` is 1. **CLI intentionally does not emit kind 8000/8001 deltas** — `publish_nip43_delta` is

View File

@@ -187,6 +187,8 @@ pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030;
pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031; pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031;
/// NIP-43: Change the role of an existing relay member. /// NIP-43: Change the role of an existing relay member.
pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032; 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 announcement events (relay-signed)
/// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention). /// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention).
pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534; pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534;
@@ -455,6 +457,7 @@ pub const ALL_KINDS: &[u32] = &[
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_CHANGE_ROLE,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
KIND_NIP43_MEMBERSHIP_LIST, KIND_NIP43_MEMBERSHIP_LIST,
KIND_NIP43_MEMBER_ADDED, KIND_NIP43_MEMBER_ADDED,
KIND_NIP43_MEMBER_REMOVED, 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 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 { pub const fn is_relay_admin_kind(kind: u32) -> bool {
matches!( matches!(
kind, 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
) )
} }

View File

@@ -321,6 +321,49 @@ impl Db {
.transpose() .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<Option<String>> {
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::<Option<String>, _>("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. /// Ensure a configured community host exists and return its row.
/// ///
/// This is the startup/config seeding path for N=1 deployments. Migrations /// This is the startup/config seeding path for N=1 deployments. Migrations

View File

@@ -471,7 +471,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect(); let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version); 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].version, 1);
assert_eq!(&*migrations[0].description, "initial schema"); assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0] assert!(migrations[0]
@@ -506,6 +506,15 @@ mod tests {
.as_str() .as_str()
.contains("CREATE TABLE git_repo_names")); .contains("CREATE TABLE git_repo_names"));
assert!(!migrations[0].sql.as_str().contains("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] #[test]

View File

@@ -32,7 +32,7 @@ use buzz_core::kind::{
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, 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_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF,
KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, 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::tenant::TenantContext;
use buzz_core::verification::verify_event; use buzz_core::verification::verify_event;
@@ -188,10 +188,12 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => { KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => {
Ok(Scope::AdminChannels) 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 if k == RELAY_ADMIN_ADD_MEMBER
|| k == RELAY_ADMIN_REMOVE_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) Ok(Scope::AdminUsers)
} }
@@ -367,6 +369,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| RELAY_ADMIN_ADD_MEMBER | RELAY_ADMIN_ADD_MEMBER
| RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_REMOVE_MEMBER
| RELAY_ADMIN_CHANGE_ROLE | RELAY_ADMIN_CHANGE_ROLE
| RELAY_ADMIN_SET_WORKSPACE_PROFILE
| KIND_NIP43_LEAVE_REQUEST | KIND_NIP43_LEAVE_REQUEST
// NIP-IA: identity archive/unarchive requests drive relay-global // NIP-IA: identity archive/unarchive requests drive relay-global
// archive state (8002/8003/13535) and are audited as global request // archive state (8002/8003/13535) and are audited as global request

View File

@@ -10,13 +10,17 @@
//! | 9030 | Add member | admin or owner | //! | 9030 | Add member | admin or owner |
//! | 9031 | Remove member | admin or owner | //! | 9031 | Remove member | admin or owner |
//! | 9032 | Change role | owner only | //! | 9032 | Change role | owner only |
//! | 9033 | Set workspace profile (icon) | admin or owner |
use std::sync::Arc; use std::sync::Arc;
use nostr::Event; use nostr::Event;
use tracing::{info, warn}; 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_core::tenant::TenantContext;
use buzz_db::relay_members::RemoveResult; use buzz_db::relay_members::RemoveResult;
@@ -52,7 +56,45 @@ fn extract_tag_value(event: &Event, name: &str) -> Option<String> {
None 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: /// The handler:
/// 1. Extracts the target pubkey from the `["p", ...]` tag. /// 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 let sender_member = state
.db .db
.get_relay_member(tenant.community(), &sender_hex) .get_relay_member(tenant.community(), &sender_hex)
@@ -103,6 +141,34 @@ pub async fn handle_relay_admin_event(
.map(|m| m.role.as_str()) .map(|m| m.role.as_str())
.unwrap_or(""); .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 { match kind {
// kind:9030 — Add relay member // kind:9030 — Add relay member
k if k == RELAY_ADMIN_ADD_MEMBER => { k if k == RELAY_ADMIN_ADD_MEMBER => {
@@ -364,4 +430,39 @@ mod tests {
let event = make_test_event(9030, vec![vec!["role", "admin"]]); let event = make_test_event(9030, vec![vec!["role", "admin"]]);
assert_eq!(extract_tag_value(&event, "p"), None); 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());
}
} }

View File

@@ -27,6 +27,10 @@ pub struct RelayInfo {
pub name: String, pub name: String,
/// Human-readable relay description. /// Human-readable relay description.
pub description: String, 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<String>,
/// Relay operator's public key (hex), if published. /// Relay operator's public key (hex), if published.
pub pubkey: Option<String>, pub pubkey: Option<String>,
/// Contact address for the relay operator. /// Contact address for the relay operator.
@@ -114,6 +118,10 @@ impl RelayInfo {
/// unconditionally) requires clients to verify those events against /// unconditionally) requires clients to verify those events against
/// `self`. Pass `Some` whenever the relay has a stable signing key. /// `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 /// `advertise_nip43` controls whether NIP-43 (relay membership) is added
/// to `supported_nips`. Set `true` only when the relay actually emits and /// 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 /// 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`. /// programmer error to advertise NIP-43 without a `relay_self`.
pub fn build( pub fn build(
relay_self: Option<&str>, relay_self: Option<&str>,
icon: Option<&str>,
advertise_nip43: bool, advertise_nip43: bool,
max_message_length: usize, max_message_length: usize,
) -> Self { ) -> Self {
@@ -137,6 +146,7 @@ impl RelayInfo {
Self { Self {
name: "Buzz Relay".to_string(), name: "Buzz Relay".to_string(),
description: "Buzz — private team communication 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, pubkey: None,
contact: None, contact: None,
supported_nips, supported_nips,
@@ -152,13 +162,51 @@ impl RelayInfo {
/// Axum handler that returns the NIP-11 relay information document as JSON. /// Axum handler that returns the NIP-11 relay information document as JSON.
pub async fn relay_info_handler( pub async fn relay_info_handler(
axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::state::AppState>>, axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::state::AppState>>,
headers: axum::http::HeaderMap,
) -> axum::response::Json<RelayInfo> { ) -> axum::response::Json<RelayInfo> {
let (relay_self, advertise_nip43) = nip11_facts(&state); let raw_host = headers
axum::response::Json(RelayInfo::build( .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(), relay_self.as_deref(),
icon.as_deref(),
advertise_nip43, advertise_nip43,
state.config.max_frame_bytes, 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<String> {
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: /// 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<String>, bo
/// ///
/// The conformance obligation: `RelayInfo::build` "must not grow unscoped /// The conformance obligation: `RelayInfo::build` "must not grow unscoped
/// DB/search/audit inputs", so an unauthenticated NIP-11 read can never become /// 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 /// 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 /// This const binds `RelayInfo::build` to its **exact** allowed signature. The
/// moment someone adds a `&Db`, `&AppState`, a search handle, an audit handle, /// 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<String>, bo
/// hard build break, the same way a deny-lint would. If you must change this /// 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 /// signature, you are changing the conformance contract: update the conformance
/// doc and prove the new input is host-scoped, not unscoped, first. /// 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 = const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
RelayInfo::build; Option<&str>,
Option<&str>,
bool,
usize,
) -> RelayInfo = RelayInfo::build;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -227,10 +283,42 @@ mod tests {
#[test] #[test]
fn build_advertises_buzz_repository_url() { 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"); 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] #[test]
fn auth_required_is_advertised_true() { fn auth_required_is_advertised_true() {
// REQ, EVENT, and COUNT all unconditionally require // REQ, EVENT, and COUNT all unconditionally require
@@ -241,7 +329,7 @@ mod tests {
#[test] #[test]
fn max_message_length_uses_configured_frame_limit() { 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"); let limitation = info.limitation.expect("limitation");
assert_eq!(limitation.max_message_length, Some(262_144)); 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. /// Open relay, ephemeral key — both `self` and NIP-43 are absent.
#[test] #[test]
fn build_open_relay_ephemeral_key_omits_self_and_nip43() { 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.relay_self.is_none());
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
} }
@@ -285,7 +373,7 @@ mod tests {
#[test] #[test]
fn build_open_relay_stable_key_advertises_self_but_not_nip43() { fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001"; 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_eq!(info.relay_self.as_deref(), Some(pk));
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
} }
@@ -294,7 +382,7 @@ mod tests {
#[test] #[test]
fn build_membership_relay_advertises_self_and_nip43() { fn build_membership_relay_advertises_self_and_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001"; 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_eq!(info.relay_self.as_deref(), Some(pk));
assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
} }
@@ -305,6 +393,6 @@ mod tests {
#[test] #[test]
#[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
fn build_nip43_without_self_panics_in_debug() { 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);
} }
} }

View File

@@ -21,7 +21,7 @@ use crate::api;
use crate::audio; use crate::audio;
use crate::connection::handle_connection; use crate::connection::handle_connection;
use crate::metrics::track_metrics; 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; use crate::state::AppState;
/// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. /// 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()) .and_then(|v| v.to_str().ok())
.unwrap_or(""); .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") { if accept.contains("application/nostr+json") {
let info = RelayInfo::build( return Json(nip11_document(&state, raw_host).await).into_response();
relay_self.as_deref(),
advertise_nip43,
state.config.max_frame_bytes,
);
return Json(info).into_response();
} }
// Row zero: bind the connection to its community from the request host // 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 // 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 // connection. The host is the authoritative selector; an unmapped host or a
// lookup failure fails closed with a generic rejection — never a default // lookup failure fails closed with a generic rejection — never a default
// tenant. NIP-11 above is intentionally host-agnostic (static facts only), // tenant. NIP-11 above is served before binding and stays fail-open: an
// so it is served before binding and cannot leak which hosts are mapped. // unmapped host still gets the document (with host-scoped fields like
let raw_host = headers // `icon` simply absent), so the doc cannot leak which hosts are mapped.
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { let tenant = match crate::tenant::bind_community(&state.db, raw_host).await {
Ok(ctx) => ctx, Ok(ctx) => ctx,
Err(_) => { 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. // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
let info = RelayInfo::build( Json(nip11_document(&state, raw_host).await).into_response()
relay_self.as_deref(),
advertise_nip43,
state.config.max_frame_bytes,
);
Json(info).into_response()
} }
} }
} }

View File

@@ -454,14 +454,17 @@ mod nip11_relay_info {
/// input; this test proves the *observable wire behavior* — that the served /// input; this test proves the *observable wire behavior* — that the served
/// document carries nothing that distinguishes one community from another. /// document carries nothing that distinguishes one community from another.
/// ///
/// Because `RelayInfo::build` is genuinely static-input today, host A's and /// One field is deliberately host-scoped: `icon` (the NIP-WP workspace
/// host B's NIP-11 bodies are byte-identical, and that identity *is* the /// icon, per-community state served in the standard NIP-11 field, fetched
/// proof: no field varies by community, so an unauthenticated reader cannot /// through `bind_community` so it can only ever be the requesting host's
/// use the document to probe whether (or how) community B is configured. /// own community state — intentionally public presentation, exactly what
/// The moment a per-community value leaks into the doc, the two bodies /// upstream NIP-11 `icon` is). So the
/// diverge and this assertion fails — that is the mutate-bite this row /// oracle proof is: the documents are identical *modulo each community's
/// guards (seed a community-distinguishing field into the served doc → the /// own `icon`*, and an unmapped host's document carries no `icon` at all.
/// A≡B assertion goes red). /// 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] #[tokio::test]
#[ignore] #[ignore]
async fn nip11_is_not_a_cross_community_enumeration_oracle() { 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 // Both bodies must be valid NIP-11 JSON — a relay-info object, not an
// error page or a host echo. // 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"); 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"); serde_json::from_str(&body_b).expect("host B NIP-11 is valid JSON");
assert!( assert!(
json_a.get("supported_nips").is_some(), 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)" "host B NIP-11 must be a relay-info document (has supported_nips)"
); );
// The enumeration-oracle obligation: no field of the served document // `icon` is the one deliberately host-scoped field (the NIP-WP
// varies by community. Identical bodies are the proof that the doc // workspace icon; each host sees only its own community's icon).
// cannot be used to distinguish or probe another tenant. // 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!( assert_eq!(
json_a, json_b, json_a, json_b,
"NIP-11 from host A and host B must be identical: any community-\ "NIP-11 from host A and host B must be identical apart from each \
distinguishing field would make the unauthenticated relay-info \ community's own `icon`: any other community-distinguishing field \
document an enumeration oracle for other tenants" 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 // 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 = let json_unknown: serde_json::Value =
serde_json::from_str(&body_unknown).expect("unmapped-host NIP-11 is valid JSON"); 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!( assert_eq!(
json_a, json_unknown, json_a, json_unknown,
"NIP-11 served to an unmapped host must be byte-identical to a mapped \ "NIP-11 served to an unmapped host must match a mapped host's \
host's document: the relay-info doc carries no host-derived field, \ icon-stripped document: apart from the host's own `icon`, the \
so it cannot reveal whether a given host is configured" relay-info doc carries no host-derived field, so it cannot reveal \
whether a given host is configured"
); );
} }
} }

View File

@@ -1,5 +1,5 @@
use nostr::Keys; use nostr::Keys;
use serde::Serialize; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use crate::app_state::AppState; use crate::app_state::AppState;
@@ -9,6 +9,43 @@ use crate::managed_agents::{
}; };
use crate::relay; use crate::relay;
#[derive(Deserialize)]
struct RelayInfoIcon {
#[serde(default)]
icon: Option<String>,
}
/// 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<Option<String>, 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::<RelayInfoIcon>()
.await
.unwrap_or(RelayInfoIcon { icon: None });
Ok(doc.icon.filter(|icon| !icon.is_empty()))
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct ActiveWorkspaceInfo { pub struct ActiveWorkspaceInfo {
relay_url: String, relay_url: String,

View File

@@ -607,6 +607,7 @@ pub fn run() {
apply_workspace, apply_workspace,
validate_repos_dir, validate_repos_dir,
get_active_workspace, get_active_workspace,
fetch_workspace_icon,
set_prevent_sleep_active, set_prevent_sleep_active,
get_agent_memory, get_agent_memory,
relay_reconnect_hook, relay_reconnect_hook,

View File

@@ -37,6 +37,7 @@ import {
} from "@/shared/ui/dropdown-menu"; } from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
import { WorkspaceIconSettingsCard } from "@/features/workspaces/ui/WorkspaceIconSettingsCard";
import { VirtualizedList } from "@/shared/ui/VirtualizedList"; import { VirtualizedList } from "@/shared/ui/VirtualizedList";
type AssignableRelayRole = Exclude<RelayMemberRole, "owner">; type AssignableRelayRole = Exclude<RelayMemberRole, "owner">;
@@ -365,6 +366,7 @@ export function RelayMembersSettingsCard({
/> />
<div className="space-y-6"> <div className="space-y-6">
<WorkspaceIconSettingsCard />
<form className="space-y-1.5" onSubmit={handleAddMember}> <form className="space-y-1.5" onSubmit={handleAddMember}>
<label className="text-sm font-medium" htmlFor="relay-member-pubkey"> <label className="text-sm font-medium" htmlFor="relay-member-pubkey">
Invite a person Invite a person

View File

@@ -1,6 +1,7 @@
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import type { Workspace } from "@/features/workspaces/types"; import type { Workspace } from "@/features/workspaces/types";
import { useWorkspaceIcons } from "@/features/workspaces/useWorkspaceIcons";
import { import {
useWorkspaceUnread, useWorkspaceUnread,
type WorkspaceUnreadState, type WorkspaceUnreadState,
@@ -54,11 +55,13 @@ function WorkspaceButton({
workspace, workspace,
isActive, isActive,
unread, unread,
iconUrl,
onSwitch, onSwitch,
}: { }: {
workspace: Workspace; workspace: Workspace;
isActive: boolean; isActive: boolean;
unread: WorkspaceUnreadState; unread: WorkspaceUnreadState;
iconUrl: string | null;
onSwitch: () => void; onSwitch: () => void;
}) { }) {
const { mentionCount, showBadge, pending, badgeLabel } = const { mentionCount, showBadge, pending, badgeLabel } =
@@ -81,14 +84,24 @@ function WorkspaceButton({
> >
<span <span
className={cn( className={cn(
"flex h-9 w-9 items-center justify-center rounded-2xl text-xs font-semibold transition-all", "flex h-9 w-9 items-center justify-center overflow-hidden rounded-2xl text-xs font-semibold transition-all",
isActive isActive
? "rounded-xl bg-primary text-primary-foreground" ? "rounded-xl bg-primary text-primary-foreground"
: "bg-sidebar-accent/60 text-sidebar-foreground/80 hover:rounded-xl hover:bg-primary/80 hover:text-primary-foreground", : "bg-sidebar-accent/60 text-sidebar-foreground/80 hover:rounded-xl hover:bg-primary/80 hover:text-primary-foreground",
pending && "opacity-60", pending && "opacity-60",
)} )}
> >
{workspaceInitials(workspace.name) || "🐝"} {iconUrl ? (
<img
alt=""
className="h-full w-full object-cover"
data-testid={`workspace-rail-icon-${workspace.id}`}
draggable={false}
src={iconUrl}
/>
) : (
workspaceInitials(workspace.name) || "🐝"
)}
</span> </span>
{showBadge ? ( {showBadge ? (
<span <span
@@ -119,6 +132,7 @@ export function WorkspaceRail({
onAddWorkspace, onAddWorkspace,
}: WorkspaceRailProps) { }: WorkspaceRailProps) {
const unreadByWorkspace = useWorkspaceUnread(workspaces, activeWorkspaceId); const unreadByWorkspace = useWorkspaceUnread(workspaces, activeWorkspaceId);
const iconsByWorkspace = useWorkspaceIcons(workspaces);
const isFullscreen = useIsFullscreen(); const isFullscreen = useIsFullscreen();
if (workspaces.length <= 1) { if (workspaces.length <= 1) {
return null; return null;
@@ -142,6 +156,7 @@ export function WorkspaceRail({
{workspaces.map((workspace) => ( {workspaces.map((workspace) => (
<WorkspaceButton <WorkspaceButton
key={workspace.id} key={workspace.id}
iconUrl={iconsByWorkspace[workspace.id] ?? null}
isActive={workspace.id === activeWorkspaceId} isActive={workspace.id === activeWorkspaceId}
onSwitch={() => onSwitchWorkspace(workspace.id)} onSwitch={() => onSwitchWorkspace(workspace.id)}
unread={ unread={

View File

@@ -0,0 +1,37 @@
/**
* Downscale an image file to a small square data-URL for use as a workspace
* icon. The result is inlined into the kind:9033 command (and the NIP-11
* document the relay serves) so it renders
* across workspaces without cross-relay media fetches; the relay caps icon
* data-URLs at 96 KB, and 128px WebP/PNG output stays far under that.
*/
const ICON_SIZE = 128;
export async function downscaleIconToDataUrl(file: File): Promise<string> {
const bitmap = await createImageBitmap(file);
try {
const side = Math.min(bitmap.width, bitmap.height);
const sx = (bitmap.width - side) / 2;
const sy = (bitmap.height - side) / 2;
const canvas = document.createElement("canvas");
canvas.width = ICON_SIZE;
canvas.height = ICON_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not process that image.");
}
ctx.imageSmoothingQuality = "high";
ctx.drawImage(bitmap, sx, sy, side, side, 0, 0, ICON_SIZE, ICON_SIZE);
// WebP keeps transparency and compresses well; fall back to PNG when the
// WebView cannot encode WebP.
const webp = canvas.toDataURL("image/webp", 0.85);
return webp.startsWith("data:image/webp")
? webp
: canvas.toDataURL("image/png");
} finally {
bitmap.close();
}
}

View File

@@ -0,0 +1,137 @@
import { ImagePlus, Trash2 } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { downscaleIconToDataUrl } from "@/features/workspaces/lib/downscaleIcon";
import {
useActiveWorkspaceIcon,
workspaceIconQueryKey,
} from "@/features/workspaces/useWorkspaceIcons";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { workspaceInitials } from "@/features/sidebar/ui/WorkspaceRail";
import { setWorkspaceIcon } from "@/shared/api/workspaceProfile";
import { Button } from "@/shared/ui/button";
const ICON_IMAGE_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"];
/**
* Admin-only workspace icon editor, rendered inside the Relay Access
* settings section. Publishes a kind:9033 command; the relay stores
* the icon per community and serves it in NIP-11, which every member's
* rail reads.
*/
export function WorkspaceIconSettingsCard() {
const { activeWorkspace } = useWorkspaces();
const relayUrl = activeWorkspace?.relayUrl;
const iconQuery = useActiveWorkspaceIcon(relayUrl);
const queryClient = useQueryClient();
const inputRef = React.useRef<HTMLInputElement | null>(null);
const mutation = useMutation({
mutationFn: (icon: string) => setWorkspaceIcon(icon),
onSuccess: async (_data, icon) => {
if (relayUrl) {
queryClient.setQueryData(workspaceIconQueryKey(relayUrl), icon || null);
await queryClient.invalidateQueries({
queryKey: workspaceIconQueryKey(relayUrl),
});
}
},
});
async function handleFile(file: File) {
if (!ICON_IMAGE_TYPES.includes(file.type)) {
toast.error("Choose a PNG, JPG, GIF, or WebP image.");
return;
}
try {
const dataUrl = await downscaleIconToDataUrl(file);
await mutation.mutateAsync(dataUrl);
toast.success("Workspace icon updated");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to update the workspace icon.",
);
}
}
async function handleClear() {
try {
await mutation.mutateAsync("");
toast.success("Workspace icon removed");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to remove the workspace icon.",
);
}
}
const icon = iconQuery.data ?? null;
const initials = activeWorkspace
? workspaceInitials(activeWorkspace.name)
: "";
return (
<div className="space-y-1.5" data-testid="workspace-icon-settings">
<span className="text-sm font-medium">Workspace icon</span>
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-sidebar-accent/60 text-sm font-semibold text-sidebar-foreground/80">
{icon ? (
<img
alt="Workspace icon"
className="h-full w-full object-cover"
data-testid="workspace-icon-preview"
src={icon}
/>
) : (
initials || "🐝"
)}
</span>
<div className="flex items-center gap-2">
<Button
data-testid="workspace-icon-upload"
disabled={mutation.isPending}
onClick={() => inputRef.current?.click()}
type="button"
variant="outline"
>
<ImagePlus className="h-4 w-4" />
{icon ? "Replace" : "Upload"}
</Button>
{icon ? (
<Button
data-testid="workspace-icon-remove"
disabled={mutation.isPending}
onClick={() => void handleClear()}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
Remove
</Button>
) : null}
</div>
<input
accept={ICON_IMAGE_TYPES.join(",")}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleFile(file);
}}
ref={inputRef}
type="file"
/>
</div>
<p className="text-xs text-muted-foreground">
Shown to every member in the workspace rail and switcher. Square images
work best.
</p>
</div>
);
}

View File

@@ -23,11 +23,13 @@ import {
} from "@/shared/ui/sidebar"; } from "@/shared/ui/sidebar";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@/shared/lib/cn";
import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { ConnectionState } from "@/shared/api/relayClientShared";
import { import {
isRelayConnectionDegraded, isRelayConnectionDegraded,
useRelayConnection, useRelayConnection,
} from "@/shared/api/useRelayConnection"; } from "@/shared/api/useRelayConnection";
import { useActiveWorkspaceIcon } from "@/features/workspaces/useWorkspaceIcons";
import { EditWorkspaceDialog } from "./EditWorkspaceDialog"; import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = { const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
@@ -52,7 +54,28 @@ type WorkspaceSwitcherProps = {
onRemoveWorkspace: (id: string) => void; onRemoveWorkspace: (id: string) => void;
}; };
function WorkspaceEmojiIcon({ className }: { className: string }) { function WorkspaceEmojiIcon({
className,
iconUrl,
}: {
className: string;
iconUrl?: string | null;
}) {
if (iconUrl) {
return (
<span
aria-hidden="true"
className={cn(className, "h-5 overflow-hidden rounded-md")}
>
<img
alt=""
className="h-full w-full object-cover"
draggable={false}
src={iconUrl}
/>
</span>
);
}
return ( return (
<span aria-hidden="true" className={className}> <span aria-hidden="true" className={className}>
<span className="-translate-y-px leading-normal">🐝</span> <span className="-translate-y-px leading-normal">🐝</span>
@@ -76,6 +99,8 @@ export function WorkspaceSwitcher({
const connectionState = useRelayConnection(); const connectionState = useRelayConnection();
const degraded = isRelayConnectionDegraded(connectionState); const degraded = isRelayConnectionDegraded(connectionState);
const connectionLabel = CONNECTION_STATE_LABEL[connectionState]; const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
const activeIconQuery = useActiveWorkspaceIcon(activeWorkspace?.relayUrl);
const activeIcon = activeIconQuery.data ?? null;
const isProfileVariant = variant === "profile"; const isProfileVariant = variant === "profile";
function clearProfileMenuHoverTimer() { function clearProfileMenuHoverTimer() {
@@ -143,6 +168,7 @@ export function WorkspaceSwitcher({
? "flex w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-2xs" ? "flex w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-2xs"
: "flex w-5 shrink-0 items-center justify-center text-xs" : "flex w-5 shrink-0 items-center justify-center text-xs"
} }
iconUrl={activeIcon}
/> />
)} )}
<span <span

View File

@@ -0,0 +1,68 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { fetchWorkspaceIcon } from "@/shared/api/workspaceProfile";
import type { Workspace } from "./types";
import {
loadCachedWorkspaceIcon,
saveCachedWorkspaceIcon,
} from "./workspaceIconCache";
export const workspaceIconQueryKey = (relayUrl: string) =>
["workspaceIcon", relayUrl] as const;
const ICON_STALE_MS = 5 * 60_000;
async function fetchIconForWorkspace(
workspace: Workspace,
): Promise<string | null> {
const icon = await fetchWorkspaceIcon(workspace.relayUrl);
saveCachedWorkspaceIcon(workspace.relayUrl, icon);
return icon;
}
function iconQueryOptions(workspace: Workspace) {
return {
queryKey: workspaceIconQueryKey(workspace.relayUrl),
queryFn: () => fetchIconForWorkspace(workspace),
// Cached icon renders immediately; the fetch still runs and replaces it.
placeholderData: loadCachedWorkspaceIcon(workspace.relayUrl),
staleTime: ICON_STALE_MS,
retry: 1,
};
}
/**
* Workspace icons for the rail, keyed by workspace id. Each icon is read
* from its relay's NIP-11 document over plain HTTP — active and inactive
* workspaces alike. Falls back to the localStorage cache (then null →
* initials) when a relay is unreachable.
*/
export function useWorkspaceIcons(
workspaces: Workspace[],
): Record<string, string | null> {
const results = useQueries({
queries: workspaces.map((workspace) => iconQueryOptions(workspace)),
});
const icons: Record<string, string | null> = {};
workspaces.forEach((workspace, index) => {
icons[workspace.id] =
results[index]?.data ?? loadCachedWorkspaceIcon(workspace.relayUrl);
});
return icons;
}
/** Icon of the ACTIVE workspace, for settings preview. */
export function useActiveWorkspaceIcon(relayUrl: string | undefined) {
return useQuery({
queryKey: workspaceIconQueryKey(relayUrl ?? ""),
queryFn: async () => {
const icon = await fetchWorkspaceIcon(relayUrl ?? "");
if (relayUrl) saveCachedWorkspaceIcon(relayUrl, icon);
return icon;
},
enabled: relayUrl !== undefined,
staleTime: ICON_STALE_MS,
});
}

View File

@@ -0,0 +1,41 @@
/**
* Local cache of workspace icons keyed by relay URL, so the rail renders
* icons instantly on boot and for unreachable relays. Values are small
* data-URLs (or http URLs) from the relay's NIP-11 `icon` field.
*/
const ICON_CACHE_KEY = "buzz-workspace-icons";
function loadCache(): Record<string, string> {
try {
const raw = localStorage.getItem(ICON_CACHE_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : null;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, string>;
}
} catch {
// Corrupt cache — fall through to empty.
}
return {};
}
export function loadCachedWorkspaceIcon(relayUrl: string): string | null {
return loadCache()[relayUrl] ?? null;
}
export function saveCachedWorkspaceIcon(
relayUrl: string,
icon: string | null,
): void {
const cache = loadCache();
if (icon) {
cache[relayUrl] = icon;
} else {
delete cache[relayUrl];
}
try {
localStorage.setItem(ICON_CACHE_KEY, JSON.stringify(cache));
} catch {
// Quota exceeded — the icon still renders from the in-memory query.
}
}

View File

@@ -0,0 +1,51 @@
/**
* Workspace icon, Buzz extension to NIP-43 + standard NIP-11 `icon`.
*
* An admin/owner publishes a kind:9033 command carrying the icon in an
* `["icon", value]` tag; the relay validates the sender's relay role and
* stores the icon per community, serving it in the standard `icon` field of
* its NIP-11 relay information document. Every member's client reads NIP-11,
* so the whole workspace sees the same icon.
*
* The icon value is a small `data:image/*` URL (downscaled client-side
* before publish) so it renders for INACTIVE workspaces straight from the
* document — no cross-relay media fetch behind another relay's auth wall.
*/
import { relayClient } from "@/shared/api/relayClient";
import { invokeTauri, signRelayEvent } from "@/shared/api/tauri";
/** Buzz: admin command to set the workspace profile (icon). */
export const KIND_SET_WORKSPACE_PROFILE = 9033;
/**
* Fetch a workspace's icon from its relay's NIP-11 document (plain
* unauthenticated HTTP via the Tauri backend — works for inactive
* workspaces too). Unreachable relay or no icon → null.
*/
export async function fetchWorkspaceIcon(
relayUrl: string,
): Promise<string | null> {
const icon = await invokeTauri<string | null>("fetch_workspace_icon", {
relayUrl,
});
return icon || null;
}
/**
* Publish a kind:9033 command setting (or clearing, with "") the workspace
* icon on the active relay. Requires relay admin/owner role — the relay
* rejects the command otherwise.
*/
export async function setWorkspaceIcon(icon: string): Promise<void> {
const event = await signRelayEvent({
kind: KIND_SET_WORKSPACE_PROFILE,
content: "",
tags: [["icon", icon]],
});
await relayClient.publishEvent(
event,
"Timed out while updating the workspace icon.",
"Failed to update the workspace icon.",
);
}

View File

@@ -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 | | 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. | | 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 and a stable relay signing pubkey when configured. | Host-derived community for community-specific facts; no 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. | `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. 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. | | 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. |
| 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. | | 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. | | 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. | | 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. |

94
docs/nips/NIP-WP.md Normal file
View File

@@ -0,0 +1,94 @@
NIP-WP
======
Workspace Profile
-----------------
`draft` `optional` `relay`
**Depends on**: NIP-01 (basic event format), NIP-11 (relay information document), NIP-42 (Authentication of Clients to Relays), NIP-43 (Relay Access Metadata and Requests)
## Abstract
This NIP defines how a relay-scoped workspace icon is set and read. An admin or owner sets it once with a user-signed command (`kind:9033`, accepted only from relay admins/owners); the relay stores it as per-relay state and serves it in the standard `icon` field of its NIP-11 relay information document, where every client — member or not, Buzz or third-party — reads it.
The write path mirrors NIP-43's admin command shape (`kind:9030`–`9032`): user intent is validated against the relay's access-control state, then the relay updates derived state. The read path is plain NIP-11 — no new event kind is needed to consume the icon.
## Motivation
In Buzz the relay *is* the workspace ([VISION.md](../../VISION.md)). A client connected to several relays needs a way to tell them apart that every member sees identically — initials derived from a locally-configured workspace name differ per device and say nothing about the workspace itself.
Upstream Nostr already standardizes the *read* side of this: NIP-11 defines a first-class `icon` field on the relay information document, fetched with an unauthenticated `GET` + `Accept: application/nostr+json`. This NIP adopts that read path unchanged, so any NIP-11-aware client renders the workspace icon with zero Buzz-specific code.
What upstream does not provide is an in-protocol, role-gated **write** path suited to this deployment model:
- **NIP-86 (Relay Management API)** defines a `changerelayicon` method, but it is a separate JSON-RPC/HTTP surface with its own auth model, distinct from the NIP-42/NIP-43 role state Buzz relays already enforce. Buzz's admin surface is Nostr events (kinds 9030–9032); the icon write follows the same shape rather than introducing a second management protocol for one field.
- **NIP-29 group metadata** (`kind:39000` `picture`) is per-group state; the workspace icon is per-relay.
Hence one added command kind (`9033`), validated exactly like the neighboring 9030–9032 membership commands, feeding the standard NIP-11 `icon`.
## Terminology
This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as defined in RFC 2119.
- **actor**: The pubkey that signed a `kind:9033` command.
- **workspace icon**: The image identifying the workspace, carried as an `https` URL or an inline `data:image/*` URL.
## Kinds
| Kind | Name | Signer | Purpose |
|------|------|--------|---------|
| `9033` | Set Workspace Profile | admin / owner | Command: set or clear the workspace icon |
## Event Format
### `kind:9033` Set Workspace Profile
A command signed by a relay admin or owner. The icon value is carried in an `icon` tag; content is empty.
```jsonc
{
"kind": 9033,
"pubkey": "<admin-or-owner-pubkey-hex>",
"content": "",
"tags": [
["icon", "data:image/webp;base64,..."]
]
}
```
- exactly one `icon` tag. An empty value (or an absent tag) clears the icon.
- the value MUST be an `https` URL, an `http` URL, or a `data:image/*` URL. Inline data URLs are RECOMMENDED for small icons (≤128px): they render on clients connected to *other* relays without a cross-origin media fetch behind another relay's auth wall.
The `content` field is empty and carries no meaning. Relays MUST NOT parse semantics from `content`.
## Relay Processing Algorithm
When a relay receives a `kind:9033` command it MUST, before applying it:
1. Verify the event signature and NIP-42/NIP-98 authentication as usual.
2. Verify the actor holds the `admin` or `owner` role in the relay's authoritative access-control state (the same state that backs NIP-43). Reject otherwise.
3. Validate the `icon` value: empty (clear), or an `http(s)`/`data:image/*` URL containing no whitespace or control characters, within the relay's size limits. Relays SHOULD cap plain URLs (2048 bytes RECOMMENDED) and inline data URLs (96 KiB RECOMMENDED) and MUST reject non-image `data:` URLs.
On acceptance the relay stores the value as its current workspace icon (per relay — in a multi-tenant deployment, per community) and serves it in the `icon` field of its NIP-11 relay information document. A cleared icon omits the field. Last accepted command wins.
## Client Behavior
1. Fetch the relay's NIP-11 document (`GET` on the relay's HTTP endpoint with `Accept: application/nostr+json`).
2. If the document has a non-empty `icon`, render it wherever the workspace is identified (workspace rail, switcher, settings). Otherwise fall back to a local placeholder (e.g. name initials).
NIP-11 is unauthenticated, so a client can read icons for workspaces it is not currently connected to (e.g. inactive workspaces in a rail) with a plain HTTP fetch. Clients MAY cache the icon locally (keyed by relay URL) to render workspaces whose relays are currently unreachable; the cache is presentation-only and is replaced by the next fetched document.
Only admins/owners can change the icon. Clients SHOULD hide the icon editor from non-admins, but the relay-side role check in §Relay Processing is the enforcement.
## Security Considerations
The icon is intentionally public presentation state: NIP-11 is an unauthenticated document, and serving the icon there means anyone who can reach the relay host can read it. Admins MUST NOT put non-public information in the icon. In a multi-tenant deployment the icon is scoped to the community resolved from the request host — a request can only ever observe the icon of the community it is already addressing, and an unmapped host receives a document with no `icon` field.
Icon values are rendered as images by every member's client, so the relay MUST validate them at the write path: scheme allow-list (`http(s)` / `data:image/*` only — never `javascript:` or non-image `data:` types), no whitespace or control characters, and size caps. Clients render the value in an `<img>`-equivalent sink only, never as HTML.
## Relation to Other NIPs
- **NIP-11 (Relay Information Document)**: Supplies the standard `icon` field and the unauthenticated read path this NIP feeds. Buzz adds nothing to the read side.
- **NIP-43 (Relay Access Metadata and Requests)**: Supplies the role state (`admin` / `owner`) that authorizes `kind:9033`, and the admin-command shape (`9030`–`9032`) it extends.
- **NIP-86 (Relay Management API)**: Standardizes `changerelayicon` over a separate JSON-RPC management surface; this NIP achieves the same mutation in-protocol, gated by the NIP-43 role state the relay already enforces (see §Motivation).

View File

@@ -0,0 +1,12 @@
-- ── Per-community workspace icon (NIP-11 `icon`) ─────────────────────────────
-- Set by relay admins/owners via the kind:9033 command; served to clients in
-- the standard NIP-11 relay information document `icon` field, bound to the
-- community resolved from the request Host (same row-zero seam as WS/NIP-05).
--
-- Additive migration: previously applied files must not change checksum.
-- `communities` is an operator-global registry table (no community_id column
-- by design); this adds a per-row presentation attribute, not tenant data in
-- a shared table. TEXT holds either an http(s) URL or a small data:image/*
-- URL — validated and size-capped at the 9033 write path, not here.
ALTER TABLE communities ADD COLUMN icon TEXT;