mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -4899,6 +4899,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"rodio",
|
||||
"rubato",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user