Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]` |
| 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["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`:

Expand Down Expand Up @@ -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
```

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
Expand Down
11 changes: 9 additions & 2 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
}

Expand Down
43 changes: 43 additions & 0 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.
///
/// This is the startup/config seeding path for N=1 deployments. Migrations
Expand Down
11 changes: 10 additions & 1 deletion crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 6 additions & 3 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
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)
}
Expand Down Expand Up @@ -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
Expand Down
113 changes: 107 additions & 6 deletions crates/buzz-relay/src/handlers/relay_admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -52,7 +56,45 @@ fn extract_tag_value(event: &Event, name: &str) -> Option<String> {
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.
Expand Down Expand Up @@ -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)
Expand All @@ -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 => {
Expand Down Expand Up @@ -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());
}
}
Loading
Loading