diff --git a/crates/sprout-cli/src/client.rs b/crates/sprout-cli/src/client.rs index a0201e521..e3d2eb8db 100644 --- a/crates/sprout-cli/src/client.rs +++ b/crates/sprout-cli/src/client.rs @@ -171,6 +171,11 @@ impl SproutClient { &self.relay_url } + /// Whether this client is in serverless mode (generic relay, plain WS). + pub fn is_serverless(&self) -> bool { + self.serverless + } + /// Return the owner pubkey carried by the NIP-OA auth tag, if any. /// /// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index 5c78c394d..3e8d5d244 100644 --- a/crates/sprout-cli/src/commands/channels.rs +++ b/crates/sprout-cli/src/commands/channels.rs @@ -327,6 +327,29 @@ pub async fn cmd_create_channel( "forum" => sprout_sdk::ChannelKind::Forum, _ => unreachable!(), }; + if client.is_serverless() { + // No relay to process kind:9007 — publish 39000 metadata + 39002 + // membership (self) directly so the channel is discoverable. + let cid = channel_uuid.to_string(); + let meta = sprout_sdk::build_channel_metadata_serverless( + &cid, + name, + visibility, + channel_type, + description, + &[], + ) + .map_err(|e| CliError::Other(format!("build metadata failed: {e}")))?; + client.submit_event(client.sign_event(meta)?).await?; + + let me = client.keys().public_key().to_hex(); + let members = sprout_sdk::build_channel_members_serverless(&cid, &[me]) + .map_err(|e| CliError::Other(format!("build members failed: {e}")))?; + let resp = client.submit_event(client.sign_event(members)?).await?; + print_create_response(&resp, "channel_id", &cid); + return Ok(()); + } + let builder = sprout_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; @@ -337,6 +360,70 @@ pub async fn cmd_create_channel( Ok(()) } +/// Read current member pubkeys (hex, lowercased) from a channel's kind:39002 +/// event in serverless mode. Empty if none exists yet. +async fn serverless_members( + client: &SproutClient, + channel_id: &str, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1 + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw).unwrap_or_default(); + let Some(ev) = events.first() else { + return Ok(Vec::new()); + }; + let members = ev + .get("tags") + .and_then(|t| t.as_array()) + .map(|tags| { + tags.iter() + .filter_map(|t| { + let arr = t.as_array()?; + if arr.first().and_then(|v| v.as_str()) == Some("p") { + arr.get(1) + .and_then(|v| v.as_str()) + .map(|s| s.to_ascii_lowercase()) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + Ok(members) +} + +/// Add/remove pubkeys on a channel's kind:39002 member list and re-publish +/// (serverless mode read-modify-write). +async fn serverless_set_members( + client: &SproutClient, + channel_id: &str, + add: &[String], + remove: &[String], +) -> Result<(), CliError> { + let mut members = serverless_members(client, channel_id).await?; + for pk in remove { + let pk = pk.to_ascii_lowercase(); + members.retain(|m| m != &pk); + } + for pk in add { + let pk = pk.to_ascii_lowercase(); + if !members.contains(&pk) { + members.push(pk); + } + } + members.sort(); + members.dedup(); + let builder = sprout_sdk::build_channel_members_serverless(channel_id, &members) + .map_err(|e| CliError::Other(format!("build members failed: {e}")))?; + client.submit_event(client.sign_event(builder)?).await?; + Ok(()) +} + pub async fn cmd_update_channel( client: &SproutClient, channel_id: &str, @@ -394,6 +481,13 @@ pub async fn cmd_set_channel_purpose( pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { let channel_uuid = parse_uuid(channel_id)?; + if client.is_serverless() { + let me = client.keys().public_key().to_hex(); + serverless_set_members(client, &channel_uuid.to_string(), &[me], &[]).await?; + println!("{}", normalize_write_response("{\"accepted\":true}")); + return Ok(()); + } + let builder = sprout_sdk::build_join(channel_uuid) .map_err(|e| CliError::Other(format!("build_join failed: {e}")))?; @@ -406,6 +500,13 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { let channel_uuid = parse_uuid(channel_id)?; + if client.is_serverless() { + let me = client.keys().public_key().to_hex(); + serverless_set_members(client, &channel_uuid.to_string(), &[], &[me]).await?; + println!("{}", normalize_write_response("{\"accepted\":true}")); + return Ok(()); + } + let builder = sprout_sdk::build_leave(channel_uuid) .map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?; @@ -476,6 +577,18 @@ pub async fn cmd_add_channel_member( ))) } }; + if client.is_serverless() { + serverless_set_members( + client, + &channel_uuid.to_string(), + &[pubkey.to_string()], + &[], + ) + .await?; + println!("{}", normalize_write_response("{\"accepted\":true}")); + return Ok(()); + } + let builder = sprout_sdk::build_add_member(channel_uuid, pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; @@ -493,6 +606,18 @@ pub async fn cmd_remove_channel_member( validate_hex64(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; + if client.is_serverless() { + serverless_set_members( + client, + &channel_uuid.to_string(), + &[], + &[pubkey.to_string()], + ) + .await?; + println!("{}", normalize_write_response("{\"accepted\":true}")); + return Ok(()); + } + let builder = sprout_sdk::build_remove_member(channel_uuid, pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs index 4c5f98763..43c413e8f 100644 --- a/crates/sprout-sdk/src/builders.rs +++ b/crates/sprout-sdk/src/builders.rs @@ -499,6 +499,69 @@ pub fn build_create_channel( Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags)) } +// ── Serverless mode: direct addressable-event builders ────────────────────── +// +// In serverless mode there is no Sprout relay to process command events +// (kind 9007 create-channel, 9021 join, 9000 add-member) and materialize the +// resulting addressable metadata. The client builds those addressable events +// itself and publishes them straight to a generic relay. Shapes must match +// what channel parsers expect (see desktop `nostr_convert::channel_info_from_event`). + +/// Kind 39000 — channel metadata, published directly (serverless mode). +/// +/// `participants` are p-tagged (used for DM-type channels). `visibility` must +/// be `"open"` or `"private"`; DM-type channels also get a `hidden` tag. +pub fn build_channel_metadata_serverless( + channel_id: &str, + name: &str, + visibility: &str, + channel_type: &str, + about: Option<&str>, + participants: &[String], +) -> Result { + let mut tags = vec![ + tag(&["d", channel_id])?, + tag(&["name", name])?, + tag(&["t", channel_type])?, + ]; + match visibility { + "open" => tags.push(tag(&["public"])?), + "private" => tags.push(tag(&["private"])?), + other => { + return Err(SdkError::InvalidInput(format!( + "invalid visibility: {other}" + ))) + } + } + if channel_type == "dm" { + tags.push(tag(&["hidden"])?); + } + if let Some(a) = about { + if !a.is_empty() { + tags.push(tag(&["about", a])?); + } + } + for pk in participants { + tags.push(tag(&["p", &pk.to_ascii_lowercase()])?); + } + Ok(EventBuilder::new(Kind::Custom(39000), "").tags(tags)) +} + +/// Kind 39002 — channel membership, published directly (serverless mode). +/// +/// One addressable event per channel (`d`=channel_id) listing all members as +/// `p` tags. Replaceable: re-publishing supersedes the previous member list. +pub fn build_channel_members_serverless( + channel_id: &str, + member_pubkeys: &[String], +) -> Result { + let mut tags = vec![tag(&["d", channel_id])?]; + for pk in member_pubkeys { + tags.push(tag(&["p", &pk.to_ascii_lowercase()])?); + } + Ok(EventBuilder::new(Kind::Custom(39002), "").tags(tags)) +} + // ── Builder 20: build_join ─────────────────────────────────────────────────── /// Build a NIP-29 join-request event (kind 9021). diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 5148c26fc..87e7cd058 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -69,7 +69,7 @@ const overrides = new Map([ ["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail ["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow ["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs - ["src-tauri/src/commands/channels.rs", 540], // create_channel branches serverless (publish 39000+39002 directly) vs server (kind 9007 command) + channel CRUD commands + ["src-tauri/src/commands/channels.rs", 640], // serverless membership read-modify-write of kind:39002 (join/leave/add/remove publish member list directly) + create_channel branches serverless (publish 39000+39002 directly) vs server (kind 9007 command) + channel CRUD commands // create_channel branches serverless (publish 39000+39002 directly) vs server (kind 9007 command) + channel CRUD commands ["src-tauri/src/events.rs", 960], // serverless addressable builders (build_channel_metadata_serverless 39000 + build_channel_members_serverless 39002) + their tests + event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring ["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 86e42295a..6bd85e76c 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -8,6 +8,75 @@ use crate::{ relay::{query_relay, submit_event}, }; +// ── Serverless membership (read-modify-write of kind:39002) ────────────────── +// +// On a generic relay there's no server to process the membership command kinds +// (9021 join, 9022 leave, 9000 add-member, 9001 remove-member). Instead the +// client mutates the replaceable kind:39002 member-list event directly: read +// the current list, add/remove the pubkey, and re-publish the whole event. +// See docs/SPROUT_LITE_MODE.md. + +/// Fetch the current member pubkeys (hex, lowercased) for a serverless channel +/// from its kind:39002 event. Returns an empty list if none exists yet. +async fn serverless_current_members( + state: &AppState, + channel_id: &str, +) -> Result, String> { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + + let Some(ev) = events.first() else { + return Ok(Vec::new()); + }; + let members = ev + .tags + .iter() + .filter_map(|t| { + let parts = t.as_slice(); + if parts.first().map(String::as_str) == Some("p") { + parts.get(1).map(|p| p.to_ascii_lowercase()) + } else { + None + } + }) + .collect(); + Ok(members) +} + +/// Re-publish the kind:39002 member list for a serverless channel after adding +/// or removing the given pubkeys. +async fn serverless_set_members( + state: &AppState, + channel_id: &str, + add: &[String], + remove: &[String], +) -> Result<(), String> { + let mut members = serverless_current_members(state, channel_id).await?; + for pk in remove { + let pk = pk.to_ascii_lowercase(); + members.retain(|m| m != &pk); + } + for pk in add { + let pk = pk.to_ascii_lowercase(); + if !members.contains(&pk) { + members.push(pk); + } + } + members.sort(); + members.dedup(); + + let builder = events::build_channel_members_serverless(channel_id, &members)?; + submit_event(builder, state).await?; + Ok(()) +} + // ── Reads (pure-nostr via /query) ──────────────────────────────────────────── #[tauri::command] @@ -435,6 +504,17 @@ pub async fn add_channel_members( Some(other) => return Err(format!("invalid role: {other}")), }; + if state.is_serverless() { + // Validate pubkeys, then add them all to the kind:39002 member list. + let valid: Vec = pubkeys + .iter() + .filter(|p| p.len() == 64 && p.chars().all(|c| c.is_ascii_hexdigit())) + .map(|p| p.to_ascii_lowercase()) + .collect(); + serverless_set_members(&state, &uuid.to_string(), &valid, &[]).await?; + return Ok(serde_json::json!({ "added": valid, "errors": [] })); + } + let mut added = Vec::new(); let mut errors = Vec::::new(); @@ -462,6 +542,9 @@ pub async fn remove_channel_member( state: State<'_, AppState>, ) -> Result<(), String> { let uuid = parse_channel_uuid(&channel_id)?; + if state.is_serverless() { + return serverless_set_members(&state, &uuid.to_string(), &[], &[pubkey]).await; + } let builder = events::build_remove_member(uuid, &pubkey)?; submit_event(builder, &state).await?; Ok(()) @@ -482,6 +565,10 @@ pub async fn change_channel_member_role( "owner" => return Err("cannot assign owner role — use transfer ownership".into()), other => return Err(format!("invalid role: {other}")), }; + if state.is_serverless() { + // Roles aren't enforced on a generic relay; just ensure membership. + return serverless_set_members(&state, &uuid.to_string(), &[pubkey], &[]).await; + } let builder = events::build_add_member(uuid, &pubkey, Some(role_str))?; submit_event(builder, &state).await?; Ok(()) @@ -490,6 +577,13 @@ pub async fn change_channel_member_role( #[tauri::command] pub async fn join_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> { let uuid = parse_channel_uuid(&channel_id)?; + if state.is_serverless() { + let me = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + return serverless_set_members(&state, &uuid.to_string(), &[me], &[]).await; + } let builder = events::build_join(uuid)?; submit_event(builder, &state).await?; Ok(()) @@ -498,6 +592,13 @@ pub async fn join_channel(channel_id: String, state: State<'_, AppState>) -> Res #[tauri::command] pub async fn leave_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> { let uuid = parse_channel_uuid(&channel_id)?; + if state.is_serverless() { + let me = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + return serverless_set_members(&state, &uuid.to_string(), &[], &[me]).await; + } let builder = events::build_leave(uuid)?; submit_event(builder, &state).await?; Ok(())