fix(serverless): allow self-tagging on channel membership events

The 'join to participate, nothing happens' bug: when a user creates or
joins a serverless channel they p-tag themselves (the signer). nostr 0.44
strips self-referencing p tags at sign time unless .allow_self_tagging()
is set, so kind:39002 membership events were published with an EMPTY
member list. get_channels then queries 39002 by #p:[me], finds nothing,
and the UI stays stuck on 'join to participate' forever.

(This worked in the old TS/slackest codebase because nostr-tools does not
perform that scrub.)

Fix: add .allow_self_tagging() to the serverless 39000 + 39002 builders
in both desktop events.rs and the shared sprout-sdk builders (CLI/agents).

Reproduced and verified end-to-end against wss://relay.damus.io via a new
#[ignore] integration test (serverless_create_join_roundtrip): create ->
join -> both members visible. Added no-network unit regression guards
(serverless_{members,metadata}_keeps_self_p_tag) in both crates.

clippy clean (desktop+sdk+cli); desktop + sdk tests pass; file-size gate ok.
This commit is contained in:
Michael Neale
2026-06-02 08:43:09 +10:00
parent fc832f4d5d
commit 858cb3b02a
6 changed files with 284 additions and 5 deletions
+52 -2
View File
@@ -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"
);
}
}
+1 -1
View File
@@ -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
+1
View File
@@ -4899,6 +4899,7 @@ dependencies = [
"reqwest",
"rodio",
"rubato",
"rustls",
"serde",
"serde_json",
"sha2 0.11.0",
+3
View File
@@ -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"] }
@@ -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::<Vec<_>>()
);
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");
}
+56 -2
View File
@@ -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<Vec<String>> = 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<Vec<String>> = 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:?}"
);
}
}