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:
Michael Neale
2026-06-02 08:33:06 +10:00
parent c508603543
commit fc832f4d5d
5 changed files with 295 additions and 1 deletions
+1 -1
View File
@@ -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
+101
View File
@@ -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(())