mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(serverless): membership via direct kind:39002 (join/leave/add/remove) + CLI channel ops
In serverless mode there's no relay to process membership command kinds (9021 join, 9022 leave, 9000/9001 add/remove member). The client now mutates the replaceable kind:39002 member-list event directly (read-modify-write). - desktop: join/leave/add/remove_channel_member publish 39002 directly - sprout-sdk: build_channel_metadata_serverless (39000) + build_channel_members_serverless (39002), shared builders - sprout-cli: channels create/join/leave/add-member/remove-member serverless branches (fixes agent replies + CLI parity with desktop) Verified end-to-end against wss://relay.damus.io: create -> list -> send -> read all round-trip with zero Sprout infrastructure.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Vec<String>, CliError> {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [39002],
|
||||
"#d": [channel_id],
|
||||
"limit": 1
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = 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}")))?;
|
||||
|
||||
|
||||
@@ -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<EventBuilder, SdkError> {
|
||||
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<EventBuilder, SdkError> {
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Vec<String>, 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<String> = 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::<serde_json::Value>::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(())
|
||||
|
||||
Reference in New Issue
Block a user