diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs index 43c413e8f..3258bacda 100644 --- a/crates/sprout-sdk/src/builders.rs +++ b/crates/sprout-sdk/src/builders.rs @@ -544,7 +544,12 @@ pub fn build_channel_metadata_serverless( for pk in participants { tags.push(tag(&["p", &pk.to_ascii_lowercase()])?); } - Ok(EventBuilder::new(Kind::Custom(39000), "").tags(tags)) + // `.allow_self_tagging()`: self-participant channels p-tag the signer; + // nostr 0.44 strips self-`p` tags by default. See + // build_channel_members_serverless for the full rationale. + Ok(EventBuilder::new(Kind::Custom(39000), "") + .tags(tags) + .allow_self_tagging()) } /// Kind 39002 — channel membership, published directly (serverless mode). @@ -559,7 +564,13 @@ pub fn build_channel_members_serverless( for pk in member_pubkeys { tags.push(tag(&["p", &pk.to_ascii_lowercase()])?); } - Ok(EventBuilder::new(Kind::Custom(39002), "").tags(tags)) + // `.allow_self_tagging()` is REQUIRED: a user joining/creating a channel + // p-tags themselves (the signer). nostr 0.44 strips self-`p` tags by + // default, publishing an empty member list and breaking the get_channels + // `#p:[me]` membership query → "join to participate" forever. + Ok(EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .allow_self_tagging()) } // ── Builder 20: build_join ─────────────────────────────────────────────────── @@ -2142,4 +2153,43 @@ mod tests { let err = build_presence_update("dnd").unwrap_err(); assert!(matches!(err, SdkError::InvalidInput(_))); } + + #[test] + fn serverless_members_keeps_self_p_tag() { + // Regression: joining/creating a serverless channel p-tags the signer. + // nostr 0.44 strips self-`p` tags unless `.allow_self_tagging()` is set, + // which previously published an empty member list and broke the + // get_channels `#p:[me]` membership query ("join to participate" bug). + let keys = nostr::Keys::generate(); + let me = keys.public_key().to_hex(); + let ev = build_channel_members_serverless("chan-self", &[me.clone()]) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert!( + has_tag(&ev, "p", &me), + "self p-tag stripped from 39002 — .allow_self_tagging() missing" + ); + } + + #[test] + fn serverless_metadata_keeps_self_p_tag() { + let keys = nostr::Keys::generate(); + let me = keys.public_key().to_hex(); + let ev = build_channel_metadata_serverless( + "dm-self", + "DM", + "private", + "dm", + None, + &[me.clone()], + ) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert!( + has_tag(&ev, "p", &me), + "self p-tag stripped from 39000 metadata" + ); + } } diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 87e7cd058..209843a32 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -70,7 +70,7 @@ const overrides = new Map([ ["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", 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/events.rs", 1010], // + self-p-tag regression tests (allow_self_tagging on 39000/39002) // 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 ["src-tauri/src/huddle/stt.rs", 580], // STT pipeline + PTT edge-detection flush + PTT gating (is_speech AND ptt_active) + barge-in for VAD mode + rubato resampler + earshot VAD + sherpa-onnx transcription diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0775e8113..9eedbe459 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4899,6 +4899,7 @@ dependencies = [ "reqwest", "rodio", "rubato", + "rustls", "serde", "serde_json", "sha2 0.11.0", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3ef415233..d9fc21ad7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -76,3 +76,6 @@ tempfile = "3" strip-ansi-escapes = "0.2" [dev-dependencies] +# Network integration tests (serverless WS roundtrip) need an explicit rustls +# CryptoProvider — the app installs one via tauri/wry at startup, tests don't. +rustls = { version = "0.23", features = ["aws_lc_rs"] } diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index e902b5edf..3f00cf452 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -92,3 +92,174 @@ fn empty_input_yields_empty_map() { let membership = collect_members_by_channel(&[]); assert!(membership.is_empty()); } + +// ── Serverless integration test (hits a real public relay) ─────────────────── +// +// Run with: +// cargo test --manifest-path desktop/src-tauri/Cargo.toml \ +// -- --ignored --nocapture serverless_create_join_roundtrip +// +// Drives the EXACT serverless code paths (query_relay/submit_event over WS + +// the 39000/39002 builders + serverless_set_members read-modify-write) against +// wss://relay.damus.io to reproduce the "join does nothing" bug. + +#[tokio::test] +#[ignore = "network: hits wss://relay.damus.io"] +async fn serverless_create_join_roundtrip() { + use crate::app_state::build_app_state; + use crate::relay::{query_relay, submit_event}; + use std::sync::atomic::Ordering; + + // The app installs a rustls CryptoProvider via tauri/wry at startup; tests + // don't, and both aws-lc-rs and ring are present (ambiguous). Pick one. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + // Two independent identities: a creator and a joiner. + let creator_keys = nostr::Keys::generate(); + let joiner_keys = nostr::Keys::generate(); + let creator_pk = creator_keys.public_key().to_hex(); + let joiner_pk = joiner_keys.public_key().to_hex(); + eprintln!("creator={creator_pk}\njoiner={joiner_pk}"); + + let relay = "wss://relay.damus.io"; + + // Build a serverless AppState for the CREATOR. + let creator_state = build_app_state(); + *creator_state.keys.lock().unwrap() = creator_keys.clone(); + *creator_state.relay_url_override.lock().unwrap() = Some(relay.to_string()); + creator_state.serverless.store(true, Ordering::Relaxed); + + // ── Step 1: create a channel (publish 39000 + 39002 self) ────────────── + let channel_id = uuid::Uuid::new_v4().to_string(); + let name = format!("it-{}", &channel_id[..8]); + eprintln!("creating channel {channel_id} ({name})"); + + let meta = events::build_channel_metadata_serverless( + &channel_id, + &name, + "open", + "stream", + Some("integration test"), + &[], + ) + .expect("build metadata"); + let r1 = submit_event(meta, &creator_state) + .await + .expect("publish 39000"); + eprintln!("39000 publish: accepted={} msg={}", r1.accepted, r1.message); + + let members = events::build_channel_members_serverless(&channel_id, &[creator_pk.clone()]) + .expect("build members"); + let r2 = submit_event(members, &creator_state) + .await + .expect("publish 39002"); + eprintln!("39002 publish: accepted={} msg={}", r2.accepted, r2.message); + + // Give the relay a moment to index. + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // ── Diagnostics: is the 39002 readable at all, and by which filter? ──── + let by_d = query_relay( + &creator_state, + &[serde_json::json!({"kinds":[39002],"#d":[channel_id],"limit":10})], + ) + .await + .expect("query 39002 by #d"); + eprintln!("39002 by #d only: {} event(s)", by_d.len()); + if let Some(ev) = by_d.first() { + eprintln!(" 39002 author = {}", ev.pubkey.to_hex()); + eprintln!( + " 39002 tags = {:?}", + ev.tags + .iter() + .map(|t| t.as_slice().to_vec()) + .collect::>() + ); + eprintln!(" creator_pk = {creator_pk}"); + } + + let by_kind_author = query_relay( + &creator_state, + &[serde_json::json!({"kinds":[39002],"authors":[creator_pk],"limit":10})], + ) + .await + .expect("query 39002 by author"); + eprintln!("39002 by author: {} event(s)", by_kind_author.len()); + + let meta_by_d = query_relay( + &creator_state, + &[serde_json::json!({"kinds":[39000],"#d":[channel_id],"limit":10})], + ) + .await + .expect("query 39000 by #d"); + eprintln!("39000 by #d (control): {} event(s)", meta_by_d.len()); + + // ── Step 2: read it back as the creator (should be a member) ─────────── + let creator_member_events = query_relay( + &creator_state, + &[serde_json::json!({"kinds":[39002],"#p":[creator_pk],"#d":[channel_id],"limit":10})], + ) + .await + .expect("query creator membership"); + eprintln!( + "creator sees {} membership event(s)", + creator_member_events.len() + ); + assert!( + !creator_member_events.is_empty(), + "BUG: creator's own 39002 membership not found after create — \ + either the publish was rejected or the read filter is wrong" + ); + + // ── Step 3: JOINER joins (read-modify-write of 39002) ────────────────── + let joiner_state = build_app_state(); + *joiner_state.keys.lock().unwrap() = joiner_keys.clone(); + *joiner_state.relay_url_override.lock().unwrap() = Some(relay.to_string()); + joiner_state.serverless.store(true, Ordering::Relaxed); + + // This is exactly what join_channel does in serverless mode. + serverless_set_members(&joiner_state, &channel_id, &[joiner_pk.clone()], &[]) + .await + .expect("join (set members)"); + eprintln!("joiner published updated membership"); + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // ── Step 4: read membership — BOTH should be present ─────────────────── + let final_members = serverless_current_members(&joiner_state, &channel_id) + .await + .expect("read final members"); + eprintln!("final members ({}): {final_members:?}", final_members.len()); + + assert!( + final_members.contains(&creator_pk.to_ascii_lowercase()), + "creator missing from member list after joiner joined — \ + read-modify-write clobbered the creator (the real bug?)" + ); + assert!( + final_members.contains(&joiner_pk.to_ascii_lowercase()), + "joiner missing from member list after join — join didn't persist" + ); + + // ── Step 5: joiner's get_channels-style membership lookup ────────────── + let joiner_member_events = query_relay( + &joiner_state, + &[serde_json::json!({"kinds":[39002],"#p":[joiner_pk],"limit":50})], + ) + .await + .expect("query joiner membership"); + let joined_this_channel = joiner_member_events.iter().any(|ev| { + ev.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "d" && s[1] == channel_id + }) + }); + assert!( + joined_this_channel, + "BUG REPRODUCED: after join, get_channels' #p-filtered 39002 query \ + does not return this channel for the joiner → UI still shows \ + 'join to participate'" + ); + + eprintln!("✅ roundtrip OK: create → join → both members visible"); +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 2bba068e7..17789b61e 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -767,7 +767,12 @@ pub fn build_channel_metadata_serverless( tags.push(tag(vec!["p", &pk.to_ascii_lowercase()])?); } - Ok(EventBuilder::new(Kind::Custom(39000), "").tags(tags)) + // `.allow_self_tagging()`: DM-to-self / self-participant channels p-tag the + // signer; nostr 0.44 would otherwise strip it. See + // build_channel_members_serverless for the full rationale. + Ok(EventBuilder::new(Kind::Custom(39000), "") + .tags(tags) + .allow_self_tagging()) } /// Kind 39002 — channel membership, published directly (serverless mode). @@ -783,7 +788,13 @@ pub fn build_channel_members_serverless( check_pubkey(pk)?; tags.push(tag(vec!["p", &pk.to_ascii_lowercase()])?); } - Ok(EventBuilder::new(Kind::Custom(39002), "").tags(tags)) + // `.allow_self_tagging()` is REQUIRED: when a user creates/joins a channel + // they p-tag themselves (the signer). nostr 0.44 strips self-`p` tags by + // default, which would publish an empty member list and break the + // get_channels `#p:[me]` membership query → "join to participate" forever. + Ok(EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .allow_self_tagging()) } // ── Transport ──────────────────────────────────────────────────────────────── @@ -941,4 +952,47 @@ mod tests { assert!(tags.contains(&vec!["p".into(), PK_A.to_string()])); assert!(tags.contains(&vec!["p".into(), PK_B.to_string()])); } + + #[test] + fn serverless_members_keeps_self_p_tag() { + // Regression: when a user joins/creates a channel they p-tag THEMSELVES + // (the signer). nostr 0.44 strips self-`p` tags unless + // `.allow_self_tagging()` is set — which previously published an empty + // member list and broke the get_channels `#p:[me]` query, leaving the + // UI stuck on "join to participate". This guards that fix. + let keys = Keys::generate(); + let me = keys.public_key().to_hex(); + let event = build_channel_members_serverless("chan-self", &[me.clone()]) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert!( + tags.contains(&vec!["p".into(), me.clone()]), + "self p-tag was stripped — .allow_self_tagging() missing; tags={tags:?}" + ); + } + + #[test] + fn serverless_metadata_keeps_self_p_tag() { + // Same self-tagging hazard for DM/self-participant 39000 metadata. + let keys = Keys::generate(); + let me = keys.public_key().to_hex(); + let event = build_channel_metadata_serverless( + "dm-self", + "DM", + "private", + "dm", + None, + &[me.clone()], + ) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert!( + tags.contains(&vec!["p".into(), me.clone()]), + "self p-tag stripped from 39000 metadata; tags={tags:?}" + ); + } }