mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(serverless): resolve replaceable events to latest copy; publish agent profile
Membership/metadata reads used limit:1 + first(), picking an arbitrary kind:39002/39000 copy when relays disagreed. A stale copy on one relay (e.g. a dropped write) could be read and then clobber members on the next read-modify-write. Add latest_event/latest_by_d_tag (max created_at, id tie-break) and apply at all four read sites; drop the limit truncation in get_channels. Agents only published presence (kind:20001), never a kind:0 profile, so clients showed a raw hex pubkey. Add publish_profile (name/display_name/ about/picture) at startup, sourced from the persona display_name with a title-cased fallback from the agent command (goose -> Goose). Tests: membership latest-wins resolution + profile metadata/name fallback.
This commit is contained in:
+118
-33
@@ -478,6 +478,13 @@ pub struct Config {
|
||||
/// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL).
|
||||
/// Populated from persona pack resolution. Empty when no pack is configured.
|
||||
pub persona_env_vars: Vec<(String, String)>,
|
||||
/// Agent profile (NIP-01 kind:0 metadata) published at startup so clients
|
||||
/// render a name/avatar instead of raw hex pubkey. Sourced from the resolved
|
||||
/// persona (`display_name`, `description`, `avatar`); `None` when no persona
|
||||
/// pack is configured, in which case no profile is published.
|
||||
pub profile_name: Option<String>,
|
||||
pub profile_about: Option<String>,
|
||||
pub profile_picture: Option<String>,
|
||||
/// Whether to publish encrypted observer frames through the relay.
|
||||
pub relay_observer: bool,
|
||||
/// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate.
|
||||
@@ -582,6 +589,27 @@ pub fn propagate_legacy_env_vars() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a fallback agent profile name from the agent command, so an agent
|
||||
/// launched without a persona still publishes a readable kind:0 name instead of
|
||||
/// a bare hex pubkey. Strips any path/extension and title-cases the first
|
||||
/// letter: `"goose"` → `"Goose"`, `"/usr/bin/claude"` → `"Claude"`. Returns
|
||||
/// `None` only for an empty/whitespace command.
|
||||
fn default_profile_name_from_command(agent_command: &str) -> Option<String> {
|
||||
let base = std::path::Path::new(agent_command)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(agent_command)
|
||||
.trim();
|
||||
if base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut chars = base.chars();
|
||||
Some(match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => base.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_cli() -> Result<Self, ConfigError> {
|
||||
// Legacy env-var propagation is intentionally NOT done here.
|
||||
@@ -751,43 +779,61 @@ impl Config {
|
||||
//
|
||||
// Precedence: CLI/env args > persona values > built-in defaults.
|
||||
// Persona fills in what's missing. Explicit flags always win.
|
||||
let (persona_system_prompt, persona_model, persona_env_vars) =
|
||||
match (&args.persona_pack, &args.persona_name) {
|
||||
(Some(pack_dir), Some(name)) => {
|
||||
let pack = sprout_persona::resolve::resolve_pack(pack_dir).map_err(|e| {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let (
|
||||
persona_system_prompt,
|
||||
persona_model,
|
||||
persona_env_vars,
|
||||
persona_display_name,
|
||||
persona_about,
|
||||
persona_avatar,
|
||||
) = match (&args.persona_pack, &args.persona_name) {
|
||||
(Some(pack_dir), Some(name)) => {
|
||||
let pack = sprout_persona::resolve::resolve_pack(pack_dir).map_err(|e| {
|
||||
ConfigError::ConfigFile(format!(
|
||||
"failed to resolve pack {}: {e}",
|
||||
pack_dir.display()
|
||||
))
|
||||
})?;
|
||||
let persona = pack
|
||||
.personas
|
||||
.into_iter()
|
||||
.find(|p| p.name == *name)
|
||||
.ok_or_else(|| {
|
||||
ConfigError::ConfigFile(format!(
|
||||
"failed to resolve pack {}: {e}",
|
||||
"persona '{name}' not found in pack {}",
|
||||
pack_dir.display()
|
||||
))
|
||||
})?;
|
||||
let persona = pack
|
||||
.personas
|
||||
.into_iter()
|
||||
.find(|p| p.name == *name)
|
||||
.ok_or_else(|| {
|
||||
ConfigError::ConfigFile(format!(
|
||||
"persona '{name}' not found in pack {}",
|
||||
pack_dir.display()
|
||||
))
|
||||
})?;
|
||||
(
|
||||
Some(persona.system_prompt),
|
||||
persona.model,
|
||||
persona.goose_env_vars,
|
||||
)
|
||||
}
|
||||
(Some(_), None) => {
|
||||
return Err(ConfigError::ConfigFile(
|
||||
"--persona-pack requires --persona-name".into(),
|
||||
));
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
return Err(ConfigError::ConfigFile(
|
||||
"--persona-name requires --persona-pack".into(),
|
||||
));
|
||||
}
|
||||
(None, None) => (None, None, vec![]),
|
||||
};
|
||||
// Profile name: prefer the human display_name, fall back to slug.
|
||||
let display_name = if persona.display_name.trim().is_empty() {
|
||||
persona.name.clone()
|
||||
} else {
|
||||
persona.display_name.clone()
|
||||
};
|
||||
let about =
|
||||
(!persona.description.trim().is_empty()).then(|| persona.description.clone());
|
||||
(
|
||||
Some(persona.system_prompt),
|
||||
persona.model,
|
||||
persona.goose_env_vars,
|
||||
Some(display_name),
|
||||
about,
|
||||
persona.avatar,
|
||||
)
|
||||
}
|
||||
(Some(_), None) => {
|
||||
return Err(ConfigError::ConfigFile(
|
||||
"--persona-pack requires --persona-name".into(),
|
||||
));
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
return Err(ConfigError::ConfigFile(
|
||||
"--persona-name requires --persona-pack".into(),
|
||||
));
|
||||
}
|
||||
(None, None) => (None, None, vec![], None, None, None),
|
||||
};
|
||||
|
||||
// Apply persona defaults: CLI/env wins, persona fills gaps.
|
||||
if system_prompt.is_none() {
|
||||
@@ -818,6 +864,13 @@ impl Config {
|
||||
// and an HTTP-bridge channel-discovery crash.
|
||||
let serverless = args.serverless || args.relay_url.contains(',');
|
||||
|
||||
// Agent profile name: persona display_name if set, else a title-cased
|
||||
// fallback derived from the agent command (e.g. "goose" → "Goose") so a
|
||||
// bare agent still publishes a kind:0 profile and shows a name rather
|
||||
// than a raw hex pubkey in clients.
|
||||
let profile_name =
|
||||
persona_display_name.or_else(|| default_profile_name_from_command(&agent_command));
|
||||
|
||||
let config = Config {
|
||||
keys,
|
||||
relay_url: args.relay_url,
|
||||
@@ -850,6 +903,9 @@ impl Config {
|
||||
respond_to: args.respond_to,
|
||||
respond_to_allowlist,
|
||||
persona_env_vars,
|
||||
profile_name,
|
||||
profile_about: persona_about,
|
||||
profile_picture: persona_avatar,
|
||||
relay_observer: args.relay_observer,
|
||||
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
|
||||
no_base_prompt: args.no_base_prompt,
|
||||
@@ -1217,6 +1273,9 @@ mod tests {
|
||||
respond_to: RespondTo::Anyone,
|
||||
respond_to_allowlist: HashSet::new(),
|
||||
persona_env_vars: vec![],
|
||||
profile_name: None,
|
||||
profile_about: None,
|
||||
profile_picture: None,
|
||||
relay_observer: false,
|
||||
agent_owner: None,
|
||||
no_base_prompt: false,
|
||||
@@ -1246,6 +1305,32 @@ mod tests {
|
||||
|
||||
// ── resolve_channel_filters: Mentions mode ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn profile_name_fallback_title_cases_command() {
|
||||
assert_eq!(
|
||||
default_profile_name_from_command("goose"),
|
||||
Some("Goose".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_name_fallback_strips_path_and_extension() {
|
||||
assert_eq!(
|
||||
default_profile_name_from_command("/usr/local/bin/claude"),
|
||||
Some("Claude".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
default_profile_name_from_command("agent.sh"),
|
||||
Some("Agent".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_name_fallback_empty_is_none() {
|
||||
assert_eq!(default_profile_name_from_command(""), None);
|
||||
assert_eq!(default_profile_name_from_command(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mentions_mode_default_kinds() {
|
||||
let config = test_config(SubscribeMode::Mentions);
|
||||
|
||||
@@ -81,6 +81,55 @@ async fn publish_presence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish the agent's NIP-01 kind:0 profile metadata so clients render a name
|
||||
/// and avatar instead of the raw hex pubkey.
|
||||
///
|
||||
/// Without this, an agent only ever announces presence (kind:20001) and never
|
||||
/// tells the network who it is, so every client shows it as `2b5f20…`. We
|
||||
/// publish on startup (and it is replaceable, so re-publishing on each launch
|
||||
/// keeps name/avatar current). `name` is required; `about`/`picture` are
|
||||
/// included only when present.
|
||||
async fn publish_profile(
|
||||
publisher: &relay::RelayEventPublisher,
|
||||
keys: &nostr::Keys,
|
||||
name: &str,
|
||||
about: Option<&str>,
|
||||
picture: Option<&str>,
|
||||
) -> Result<(), relay::RelayError> {
|
||||
use nostr::{EventBuilder, Kind};
|
||||
|
||||
let content = build_profile_metadata_json(name, about, picture);
|
||||
let event = EventBuilder::new(Kind::Metadata, content)
|
||||
.tags([])
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| relay::RelayError::Http(format!("profile sign error: {e}")))?;
|
||||
publisher.publish_event(event).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the NIP-01 kind:0 metadata JSON content for an agent profile.
|
||||
///
|
||||
/// Sets both `name` and `display_name` (clients read either), and includes
|
||||
/// `about`/`picture` only when non-empty.
|
||||
fn build_profile_metadata_json(name: &str, about: Option<&str>, picture: Option<&str>) -> String {
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert("name".into(), serde_json::Value::String(name.to_string()));
|
||||
meta.insert(
|
||||
"display_name".into(),
|
||||
serde_json::Value::String(name.to_string()),
|
||||
);
|
||||
if let Some(about) = about.filter(|s| !s.trim().is_empty()) {
|
||||
meta.insert("about".into(), serde_json::Value::String(about.to_string()));
|
||||
}
|
||||
if let Some(picture) = picture.filter(|s| !s.trim().is_empty()) {
|
||||
meta.insert(
|
||||
"picture".into(),
|
||||
serde_json::Value::String(picture.to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::Value::Object(meta).to_string()
|
||||
}
|
||||
|
||||
// ── Owner resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the agent's owner pubkey at startup.
|
||||
@@ -948,6 +997,26 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2c.2: Publish agent profile (kind:0) ─────────────────────────────
|
||||
// So clients show the agent's name/avatar instead of a raw hex pubkey.
|
||||
// Only when a persona supplied a name (no persona → no profile to publish).
|
||||
if let Some(name) = config.profile_name.as_deref() {
|
||||
match publish_profile(
|
||||
&presence_publisher,
|
||||
&presence_keys,
|
||||
name,
|
||||
config.profile_about.as_deref(),
|
||||
config.profile_picture.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => tracing::info!("published agent profile (name={name})"),
|
||||
Err(e) => tracing::warn!("failed to publish agent profile: {e}"),
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("no persona display name; skipping kind:0 profile publish");
|
||||
}
|
||||
|
||||
// ── Step 2d: Resolve agent owner ────────────────────────────────────────
|
||||
// Priority: SPROUT_AUTH_TAG (NIP-OA attestation) → --agent-owner flag.
|
||||
let startup_owner: Option<String> = resolve_agent_owner(&config);
|
||||
@@ -2735,6 +2804,43 @@ mod owner_cache_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod profile_metadata_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn includes_name_and_display_name() {
|
||||
let json = build_profile_metadata_json("Goose", None, None);
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid json");
|
||||
assert_eq!(v["name"], "Goose");
|
||||
assert_eq!(v["display_name"], "Goose");
|
||||
// about/picture omitted when not supplied.
|
||||
assert!(v.get("about").is_none());
|
||||
assert!(v.get("picture").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_about_and_picture_when_present() {
|
||||
let json = build_profile_metadata_json(
|
||||
"Sami",
|
||||
Some("A helpful agent"),
|
||||
Some("https://example.com/a.png"),
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid json");
|
||||
assert_eq!(v["name"], "Sami");
|
||||
assert_eq!(v["about"], "A helpful agent");
|
||||
assert_eq!(v["picture"], "https://example.com/a.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_blank_about_and_picture() {
|
||||
let json = build_profile_metadata_json("Goose", Some(" "), Some(""));
|
||||
let v: serde_json::Value = serde_json::from_str(&json).expect("valid json");
|
||||
assert!(v.get("about").is_none(), "blank about must be omitted");
|
||||
assert!(v.get("picture").is_none(), "blank picture must be omitted");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod observer_chunk_coalescer_tests {
|
||||
use super::*;
|
||||
@@ -2872,6 +2978,9 @@ mod build_mcp_servers_tests {
|
||||
respond_to: config::RespondTo::Anyone,
|
||||
respond_to_allowlist: std::collections::HashSet::new(),
|
||||
persona_env_vars: vec![],
|
||||
profile_name: None,
|
||||
profile_about: None,
|
||||
profile_picture: None,
|
||||
relay_observer: false,
|
||||
agent_owner: None,
|
||||
no_base_prompt: false,
|
||||
|
||||
@@ -16,6 +16,59 @@ use crate::{
|
||||
// the current list, add/remove the pubkey, and re-publish the whole event.
|
||||
// See docs/SPROUT_LITE_MODE.md.
|
||||
|
||||
/// Resolve an addressable/replaceable event to its single authoritative copy.
|
||||
///
|
||||
/// kind:39002 (membership) and kind:39000 (metadata) are NIP-01 addressable
|
||||
/// events: exactly one *logical* event exists per `(kind, author, d-tag)`, and
|
||||
/// re-publishing supersedes the prior version. In a multi-relay world, however,
|
||||
/// relays can disagree — a write may land on relays A and B but be dropped
|
||||
/// (rate-limited, offline) by relay C, leaving C with a **stale** older copy.
|
||||
/// A query that fans out and merges then sees *both* versions.
|
||||
///
|
||||
/// The correct resolution per NIP-01 is "latest wins": pick the event with the
|
||||
/// greatest `created_at` (tie-break deterministically by event id). Picking an
|
||||
/// arbitrary event (e.g. `first()` after a `limit: 1` merge) is a bug — it makes
|
||||
/// the member list non-deterministic and, worse, makes read-modify-write
|
||||
/// membership updates clobber members that only exist in the newer copy.
|
||||
///
|
||||
/// Returns `None` for an empty slice.
|
||||
fn latest_event(events: &[nostr::Event]) -> Option<&nostr::Event> {
|
||||
events.iter().max_by(|a, b| {
|
||||
a.created_at
|
||||
.as_secs()
|
||||
.cmp(&b.created_at.as_secs())
|
||||
// Deterministic tie-break when two copies share a timestamp.
|
||||
.then_with(|| a.id.to_hex().cmp(&b.id.to_hex()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Group a batch of addressable events by `d`-tag and keep only the latest copy
|
||||
/// of each (see [`latest_event`] for why "latest" not "arbitrary"). Events
|
||||
/// without a `d` tag are skipped.
|
||||
fn latest_by_d_tag(events: &[nostr::Event]) -> std::collections::HashMap<String, &nostr::Event> {
|
||||
let mut by_d: std::collections::HashMap<String, &nostr::Event> =
|
||||
std::collections::HashMap::new();
|
||||
for ev in events {
|
||||
let Some(d) = ev.tags.iter().find_map(|t| {
|
||||
let s = t.as_slice();
|
||||
(s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
by_d.entry(d)
|
||||
.and_modify(|cur| {
|
||||
let newer = ev.created_at.as_secs() > cur.created_at.as_secs()
|
||||
|| (ev.created_at.as_secs() == cur.created_at.as_secs()
|
||||
&& ev.id.to_hex() > cur.id.to_hex());
|
||||
if newer {
|
||||
*cur = ev;
|
||||
}
|
||||
})
|
||||
.or_insert(ev);
|
||||
}
|
||||
by_d
|
||||
}
|
||||
|
||||
/// Fetch the current members `(pubkey, role)` for a serverless channel from its
|
||||
/// kind:39002 event. Role is the 4th element of the `p` tag (NIP-29), defaulting
|
||||
/// to `member`. Returns an empty list if no members event exists yet.
|
||||
@@ -23,17 +76,20 @@ async fn serverless_current_members(
|
||||
state: &AppState,
|
||||
channel_id: &str,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
// No `limit: 1`: relays can hold divergent copies of this replaceable
|
||||
// event (a write dropped by one relay leaves it stale). Fetch all copies
|
||||
// and resolve to the latest by `created_at` — otherwise a read-modify-write
|
||||
// membership update can be based on a stale list and silently drop members.
|
||||
let events = query_relay(
|
||||
state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [39002],
|
||||
"#d": [channel_id],
|
||||
"limit": 1
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(ev) = events.first() else {
|
||||
let Some(ev) = latest_event(&events) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let members = ev
|
||||
@@ -135,23 +191,28 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
|
||||
channel_ids.dedup();
|
||||
|
||||
// Step 2: fetch channel metadata events (kind:39000) for member channels.
|
||||
// kind:39000 is addressable: exactly one event per `d` tag, so a limit
|
||||
// equal to the number of ids is both necessary and sufficient. Without
|
||||
// an explicit limit, multi-value `#d` filters fall through to the relay's
|
||||
// default LIMIT and can drop results when there are many channels.
|
||||
let meta_events = if !channel_ids.is_empty() {
|
||||
// kind:39000 is addressable: one logical event per `d` tag. We do NOT cap
|
||||
// the limit at `channel_ids.len()`: across multiple relays each channel can
|
||||
// return several copies (fresh + stale), so a tight limit can truncate and
|
||||
// drop the fresh copy of one channel while keeping a stale copy of another.
|
||||
// Over-fetch, then resolve to the latest copy per `d` tag below.
|
||||
let meta_events_raw = if !channel_ids.is_empty() {
|
||||
query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [39000],
|
||||
"#d": channel_ids,
|
||||
"limit": channel_ids.len(),
|
||||
})],
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Deduplicate to the latest copy per channel (relays may disagree).
|
||||
let meta_events: Vec<nostr::Event> = latest_by_d_tag(&meta_events_raw)
|
||||
.into_values()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Step 3: fetch open channel metadata so the channel browser can show
|
||||
// discoverable channels the user hasn't joined yet. The relay's access
|
||||
@@ -259,15 +320,13 @@ struct ChannelMembership {
|
||||
fn collect_members_by_channel(
|
||||
events: &[nostr::Event],
|
||||
) -> std::collections::HashMap<String, ChannelMembership> {
|
||||
// Resolve each channel's `d`-tag to its latest 39002 across relays first;
|
||||
// a naive per-event insert would let a stale copy overwrite the fresh one
|
||||
// depending on iteration order.
|
||||
let latest = latest_by_d_tag(events);
|
||||
let mut map: std::collections::HashMap<String, ChannelMembership> =
|
||||
std::collections::HashMap::with_capacity(events.len());
|
||||
for ev in events {
|
||||
let Some(d) = ev.tags.iter().find_map(|t| {
|
||||
let s = t.as_slice();
|
||||
(s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
std::collections::HashMap::with_capacity(latest.len());
|
||||
for (d, ev) in latest {
|
||||
let Ok(resp) = nostr_convert::channel_members_from_event(ev) else {
|
||||
continue;
|
||||
};
|
||||
@@ -288,18 +347,17 @@ pub async fn get_channel_details(
|
||||
channel_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ChannelDetailInfo, String> {
|
||||
// Resolve the latest copy across relays (kind:39000 is replaceable too).
|
||||
let events = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [39000],
|
||||
"#d": [channel_id],
|
||||
"limit": 1
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
|
||||
events
|
||||
.first()
|
||||
latest_event(&events)
|
||||
.map(nostr_convert::channel_detail_from_event)
|
||||
.transpose()?
|
||||
.ok_or_else(|| "channel not found".to_string())
|
||||
@@ -310,18 +368,19 @@ pub async fn get_channel_members(
|
||||
channel_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ChannelMembersResponse, String> {
|
||||
// Fetch all copies and resolve the latest (relays may disagree); see
|
||||
// `latest_event`. A stale `limit: 1` pick would hide members added by a
|
||||
// write that one relay dropped.
|
||||
let events = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [39002],
|
||||
"#d": [channel_id],
|
||||
"limit": 1
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut response = events
|
||||
.first()
|
||||
let mut response = latest_event(&events)
|
||||
.map(nostr_convert::channel_members_from_event)
|
||||
.transpose()?
|
||||
.ok_or_else(|| "channel members not found".to_string())?;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// channels.rs under the per-file line cap.
|
||||
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||||
|
||||
/// Build a signed event for testing with the given kind, content, and tags.
|
||||
fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> nostr::Event {
|
||||
@@ -17,6 +17,22 @@ fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> nostr::Event {
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
/// Like [`ev`] but with an explicit `created_at` and a caller-supplied signing
|
||||
/// key — so a test can produce multiple versions of the *same* addressable
|
||||
/// event (same author + d-tag) with different timestamps to exercise
|
||||
/// "latest wins" resolution.
|
||||
fn ev_at(keys: &Keys, kind: u16, created_at: u64, tags: Vec<Vec<&str>>) -> nostr::Event {
|
||||
let parsed: Vec<Tag> = tags
|
||||
.into_iter()
|
||||
.map(|t| Tag::parse(t).expect("parse tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::from_u16(kind), "")
|
||||
.tags(parsed)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign")
|
||||
}
|
||||
|
||||
// A 64-hex pubkey (nostr p-tags require 32-byte hex).
|
||||
const PK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const PK_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
@@ -496,3 +512,177 @@ async fn serverless_live_subscription_multi_relay() {
|
||||
|
||||
state.relay_pool.unsubscribe(&sub_id).await;
|
||||
}
|
||||
|
||||
// ── Replaceable-event "latest wins" resolution ───────────────────────────────
|
||||
//
|
||||
// Regression coverage for the multi-relay membership bug: when relays hold
|
||||
// divergent copies of a replaceable kind:39002 (one relay dropped a write and
|
||||
// kept a stale copy), the client must resolve to the LATEST copy by
|
||||
// `created_at`. Picking an arbitrary copy made the member list flicker and made
|
||||
// read-modify-write membership updates silently clobber members.
|
||||
|
||||
const PK_BOT: &str = "2b5f20697e34f75726f567dcc6657b7ca4a10afc5d341ec50f34d91fd3014874";
|
||||
|
||||
#[test]
|
||||
fn latest_event_picks_newest_by_created_at() {
|
||||
let keys = Keys::generate();
|
||||
// Stale copy: 2 members. Fresh copy: 3 members (bot added later).
|
||||
let stale = ev_at(
|
||||
&keys,
|
||||
39002,
|
||||
1000,
|
||||
vec![
|
||||
vec!["d", "chan"],
|
||||
vec!["p", PK_A, "", "member"],
|
||||
vec!["p", PK_B, "", "member"],
|
||||
],
|
||||
);
|
||||
let fresh = ev_at(
|
||||
&keys,
|
||||
39002,
|
||||
2000,
|
||||
vec![
|
||||
vec!["d", "chan"],
|
||||
vec!["p", PK_A, "", "member"],
|
||||
vec!["p", PK_B, "", "member"],
|
||||
vec!["p", PK_BOT, "", "member"],
|
||||
],
|
||||
);
|
||||
|
||||
// Whatever order relays return them in, the newest must win.
|
||||
for events in [
|
||||
vec![stale.clone(), fresh.clone()],
|
||||
vec![fresh.clone(), stale.clone()],
|
||||
] {
|
||||
let chosen = latest_event(&events).expect("some event");
|
||||
assert_eq!(
|
||||
chosen.id, fresh.id,
|
||||
"must pick the newest copy regardless of input order"
|
||||
);
|
||||
let resp = nostr_convert::channel_members_from_event(chosen).expect("parse members");
|
||||
let pks: Vec<&str> = resp.members.iter().map(|m| m.pubkey.as_str()).collect();
|
||||
assert!(
|
||||
pks.contains(&PK_BOT),
|
||||
"bot added in the newer copy must be present, got {pks:?}"
|
||||
);
|
||||
assert_eq!(resp.members.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_event_empty_is_none() {
|
||||
assert!(latest_event(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_event_tie_break_is_deterministic() {
|
||||
// Two copies with the SAME created_at must resolve deterministically
|
||||
// (highest event id), not by chance/order.
|
||||
let k1 = Keys::generate();
|
||||
let k2 = Keys::generate();
|
||||
let a = ev_at(
|
||||
&k1,
|
||||
39002,
|
||||
5000,
|
||||
vec![vec!["d", "chan"], vec!["p", PK_A, "", "member"]],
|
||||
);
|
||||
let b = ev_at(
|
||||
&k2,
|
||||
39002,
|
||||
5000,
|
||||
vec![vec!["d", "chan"], vec!["p", PK_B, "", "member"]],
|
||||
);
|
||||
let expected = if a.id.to_hex() > b.id.to_hex() {
|
||||
a.id
|
||||
} else {
|
||||
b.id
|
||||
};
|
||||
assert_eq!(latest_event(&[a.clone(), b.clone()]).unwrap().id, expected);
|
||||
assert_eq!(latest_event(&[b, a]).unwrap().id, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_by_d_tag_resolves_each_channel_independently() {
|
||||
let k = Keys::generate();
|
||||
// chan-1: stale (1 member) + fresh (2 members). chan-2: single copy.
|
||||
let c1_stale = ev_at(
|
||||
&k,
|
||||
39002,
|
||||
1000,
|
||||
vec![vec!["d", "chan-1"], vec!["p", PK_A, "", "member"]],
|
||||
);
|
||||
let c1_fresh = ev_at(
|
||||
&k,
|
||||
39002,
|
||||
2000,
|
||||
vec![
|
||||
vec!["d", "chan-1"],
|
||||
vec!["p", PK_A, "", "member"],
|
||||
vec!["p", PK_BOT, "", "member"],
|
||||
],
|
||||
);
|
||||
let c2 = ev_at(
|
||||
&k,
|
||||
39002,
|
||||
1500,
|
||||
vec![vec!["d", "chan-2"], vec!["p", PK_C, "", "member"]],
|
||||
);
|
||||
|
||||
let events = [c1_stale, c2.clone(), c1_fresh.clone()];
|
||||
let map = latest_by_d_tag(&events);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(
|
||||
map.get("chan-1").unwrap().id,
|
||||
c1_fresh.id,
|
||||
"chan-1 must be the fresh copy"
|
||||
);
|
||||
assert_eq!(map.get("chan-2").unwrap().id, c2.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_by_d_tag_skips_events_without_d_tag() {
|
||||
let k = Keys::generate();
|
||||
let no_d = ev_at(&k, 39002, 1000, vec![vec!["p", PK_A, "", "member"]]);
|
||||
let with_d = ev_at(
|
||||
&k,
|
||||
39002,
|
||||
1000,
|
||||
vec![vec!["d", "chan"], vec!["p", PK_B, "", "member"]],
|
||||
);
|
||||
let events = [no_d, with_d.clone()];
|
||||
let map = latest_by_d_tag(&events);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert_eq!(map.get("chan").unwrap().id, with_d.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_members_by_channel_uses_latest_copy() {
|
||||
// The batch path (channel browser member counts) must also resolve to the
|
||||
// latest copy — a stale copy must not undercount members.
|
||||
let k = Keys::generate();
|
||||
let stale = ev_at(
|
||||
&k,
|
||||
39002,
|
||||
1000,
|
||||
vec![vec!["d", "chan-x"], vec!["p", PK_A, "", "member"]],
|
||||
);
|
||||
let fresh = ev_at(
|
||||
&k,
|
||||
39002,
|
||||
2000,
|
||||
vec![
|
||||
vec!["d", "chan-x"],
|
||||
vec!["p", PK_A, "", "member"],
|
||||
vec!["p", PK_BOT, "", "member"],
|
||||
],
|
||||
);
|
||||
// Stale listed AFTER fresh — naive last-write-wins iteration would have
|
||||
// picked the stale one; latest_by_d_tag must still choose fresh.
|
||||
let map = collect_members_by_channel(&[fresh, stale]);
|
||||
let info = map.get("chan-x").expect("channel present");
|
||||
assert_eq!(
|
||||
info.count, 2,
|
||||
"must count the fresh 2-member copy, not stale 1-member"
|
||||
);
|
||||
assert!(info.pubkeys.iter().any(|p| p == PK_BOT));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user