From 970831335465c08d8412c5851839208c7c958959 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 24 Jul 2026 11:38:48 -0700 Subject: [PATCH] =?UTF-8?q?seller=20node:=20buzz=20persona=20=E2=80=94=20k?= =?UTF-8?q?ind-0=20rate=20card=20+=20ephemeral=20presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of mobee×buzz. The seller node enrolls as a buzz inhabitant under its existing protocol identity: it publishes a NIP-01 kind-0 persona carrying a human-readable rate card and maintains a live presence heartbeat while up. Config-off (no `[buzz]` section) is fully inert. - home::BuzzConfig — optional `[buzz]` section (relay URL + persona: name, about, rate, capabilities, mint, heartbeat cadence). - seller_node::buzz — the persona subsystem: * kind-0 rate card published on boot, rate/caps/mint assembled into a human-readable `about` (rate_card_about, pure + unit-tested); * clobber guard — kind-0 is one-per-key replaceable, so before the first publish the node fetches the key's current kind-0 and REFUSES a foreign one (no mobee marker), fail-closed if it cannot read it (clobber_decision, pure + unit-tested); * presence — a live WS connection publishing an ephemeral kind-20001 PRESENCE_UPDATE every 30s; clean disconnect on shutdown clears presence, crash lets the relay TTL expire it. - One identity, one signer. The persona (and any NIP-42 auth the relay asks of the presence socket) is signed by the existing seller_node::signer actor via NodeNostrSigner, so the seller key never leaves the actor. The only signer change is a new sign_unsigned command that refuses a foreign-authored event. - SellerNode::start_buzz — the boot hook (Ok(None) when `[buzz]` absent). The `mobee sell` -> node cutover (separate slice) will call it. Zero changes to any money-path file, to buyer/, or to mcp.rs. PROTOCOL_VERSION "0" untouched. Live kind-0 + presence logic proven against an in-process nostr-relay-builder fixture (seller_node::buzz_relay_it); a deployed-relay round-trip is wired behind #[ignore] for the live check once demo keys are admitted. Stacks on #135 (seller-node-durable-store-roster). Co-Authored-By: Claude Opus 4.8 --- crates/mobee-core/src/home.rs | 43 ++ crates/mobee-core/src/seller_node/buzz.rs | 566 ++++++++++++++++++ .../src/seller_node/buzz_relay_it.rs | 399 ++++++++++++ crates/mobee-core/src/seller_node/mod.rs | 24 +- crates/mobee-core/src/seller_node/signer.rs | 142 ++++- 5 files changed, 1171 insertions(+), 3 deletions(-) create mode 100644 crates/mobee-core/src/seller_node/buzz.rs create mode 100644 crates/mobee-core/src/seller_node/buzz_relay_it.rs diff --git a/crates/mobee-core/src/home.rs b/crates/mobee-core/src/home.rs index ec177cee..e550f431 100644 --- a/crates/mobee-core/src/home.rs +++ b/crates/mobee-core/src/home.rs @@ -107,6 +107,45 @@ pub struct ProfileConfig { pub about: Option, } +/// Buzz persona config (`[buzz]` in config.toml). Absent ⇒ the feature is inert (the node opens +/// no buzz relay connection and publishes no persona). Present ⇒ the seller node enrolls as a +/// buzz inhabitant on `relay_url`: it publishes a NIP-01 kind-0 persona carrying a human-readable +/// rate card and maintains a live presence heartbeat while the node is up. The persona is signed +/// by the seller's EXISTING protocol key (one identity — no new keys); the key never lives here. +/// +/// This is discovery/identity context only — nothing here feeds the pay gate, the journal, or the +/// receipt bind. See [`crate::seller_node::buzz`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BuzzConfig { + /// The buzz relay websocket URL (e.g. `wss://buzzrelay.orveth.dev`). + pub relay_url: String, + /// Persona display name (kind-0 `name`). + pub name: String, + /// Optional human blurb, prepended to the assembled rate card in kind-0 `about`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, + /// Advertised rate (sats/job) shown in the rate card. `None` ⇒ falls back to the `[seller]` + /// `rate_sats` when a seller is configured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_sats: Option, + /// Human-readable capability tags shown in the rate card (e.g. `["code", "test"]`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, + /// Mint label shown in the rate card. `None` ⇒ `"testnut"` (the default dev mint). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mint: Option, + /// Presence heartbeat cadence (seconds). Default **30** (the deployed relay expires presence + /// after ~90s of silence, so a 30s cadence keeps it live with margin). + #[serde(default = "default_buzz_heartbeat_secs")] + pub heartbeat_secs: u64, +} + +/// serde default for [`BuzzConfig::heartbeat_secs`] — 30s (≤ the relay's ~90s presence TTL). +pub fn default_buzz_heartbeat_secs() -> u64 { + 30 +} + /// Default relay-git base (delivery). Live on mobee-relay (`/git//.git`). pub const DEFAULT_RELAY_GIT_BASE: &str = "https://mobee-relay.orveth.dev/git"; /// Shared leaf name — NOT used as default (relay name registry is global). @@ -579,6 +618,9 @@ pub struct MobeeConfig { /// Optional `[seller]` daemon config. Absent until `mobee sell` setup writes it. #[serde(default, skip_serializing_if = "Option::is_none")] pub seller: Option, + /// Optional `[buzz]` persona config. Absent ⇒ the buzz persona feature is inert. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub buzz: Option, /// Optional `[agents]` table of custom presets: name -> `{ argv = [...] }`. A custom /// entry named after a built-in preset (claude|cursor|codex) OVERRIDES that built-in. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -739,6 +781,7 @@ impl Default for MobeeConfig { allow_real_mints: false, profile: None, seller: None, + buzz: None, agents: BTreeMap::new(), seller_memory: SellerMemoryConfig::default(), seller_announce: SellerAnnounceConfig::default(), diff --git a/crates/mobee-core/src/seller_node/buzz.rs b/crates/mobee-core/src/seller_node/buzz.rs new file mode 100644 index 00000000..43dd37d9 --- /dev/null +++ b/crates/mobee-core/src/seller_node/buzz.rs @@ -0,0 +1,566 @@ +//! The seller node's **buzz persona**: the node enrolls as an inhabitant of a buzz relay under +//! its EXISTING protocol identity, publishes a NIP-01 kind-0 persona carrying a human-readable +//! rate card, and maintains a live presence heartbeat while it is up. +//! +//! One identity, one signer. The persona is signed by the same seller key the rest of the +//! protocol uses, and that key never leaves the [`signer`](super::signer) actor — this module +//! reaches it only through a [`SignerHandle`], including for the NIP-42 auth the relay may ask of +//! the presence connection (via [`NodeNostrSigner`], a nostr-sdk signer that delegates every sign +//! to the actor). No new keys, no key material in this module. +//! +//! Boundaries (charter slice 1): +//! * **Config off ⇒ inert.** With no `[buzz]` section the node opens no connection and publishes +//! nothing (see [`super::SellerNode::start_buzz`]). +//! * **Clobber guard.** kind-0 is one-per-key replaceable, so a publish CLOBBERS any existing +//! kind-0 on the key. Before the first publish the node fetches the key's current kind-0; if one +//! exists that this node did not write (no mobee marker) it REFUSES rather than overwrite a +//! foreign persona ([`clobber_decision`]). +//! * **Presence.** Deployed-relay presence is a live WS connection + a Redis TTL (~90s), refreshed +//! by a periodic ephemeral kind-20001 `"online"` status (30s cadence) — NOT stored events. A +//! clean shutdown disconnects the socket (presence clears immediately); a crash lets the relay +//! expire it within the TTL. +//! +//! This is discovery/identity context only — nothing here feeds the pay gate, the journal, or the +//! receipt bind. + +use std::time::Duration; + +use nostr_sdk::prelude::{ + BoxedFuture, Client, Event, EventBuilder, Filter, Kind, Metadata, NostrSigner, PublicKey, + SignerBackend, SignerError, Tag, UnsignedEvent, +}; + +use crate::home::BuzzConfig; + +use super::signer::SignerHandle; + +/// Ephemeral kind the deployed buzz relay consumes as a presence signal. In NIP-01's ephemeral +/// range (`20000..=29999`) so the relay never stores it — presence lives in the relay's WS + +/// Redis-TTL layer, not as a persisted event. +pub const PRESENCE_KIND: u16 = 20001; + +/// The deployed relay reads the kind-20001 **content** as a bare status string. `"online"` +/// registers/refreshes presence for the authed connection's own pubkey (the relay IGNORES tags and +/// keys on the authenticated pubkey); the relay expires it on its ~60s TTL, refreshed by the 30s +/// heartbeat. `"offline"` clears it immediately (a clean WS disconnect also clears immediately). +pub const PRESENCE_STATUS_ONLINE: &str = "online"; +/// Explicit clear status (see [`PRESENCE_STATUS_ONLINE`]). +pub const PRESENCE_STATUS_OFFLINE: &str = "offline"; + +/// NIP-42 client-authentication event kind. The relay challenges the presence connection and the +/// signer actor answers it (via [`NodeNostrSigner`]) — so it is on the raw-sign allowlist. +pub const NIP42_AUTH_KIND: u16 = 22242; + +/// The buzz signing allowlist is OWNED BY THE ACTOR ([`super::signer::UNSIGNED_SIGN_ALLOWLIST`]) so +/// the deny is enforced default-deny inside the actor for every caller, not just this wrapper. +/// Re-exported here under the buzz name for the wrapper's belt-and-suspenders check + the seam's +/// single source of truth — kind-0 persona, kind-20001 presence, kind-22242 NIP-42 auth, nothing +/// else (enumerate-entry-points doctrine). +pub use super::signer::{ + unsigned_sign_kind_allowed as buzz_signing_kind_allowed, + UNSIGNED_SIGN_ALLOWLIST as BUZZ_SIGNING_ALLOWLIST, +}; + +/// Marker tag stamped on every kind-0 this node publishes, so a later boot can tell ITS OWN +/// persona (safe to replace) from a FOREIGN kind-0 on the same key (must not be clobbered). A +/// buzz client renders kind-0 by its metadata content and ignores this tag. +pub const MOBEE_MARKER_TAG: &str = "mobee_persona"; +/// The marker tag's value. +pub const MOBEE_MARKER_VALUE: &str = "seller"; + +/// How long to wait for the relay's stored kind-0 before the clobber check (bounded; the relay +/// terminates the fetch on EOSE, so this is an upper bound, not a fixed wait). +const KIND0_FETCH_TIMEOUT_SECS: u64 = 8; +/// How long to wait for the relay connection before publishing. +const CONNECT_TIMEOUT_SECS: u64 = 10; + +/// A buzz-persona failure. Never carries key material. +#[derive(Debug)] +pub enum BuzzError { + /// Config was rejected (e.g. an unparseable relay URL or pubkey). + Config(String), + /// A relay operation failed (add/connect/fetch/publish). + Relay(String), + /// The clobber guard refused to overwrite a foreign kind-0 already on the key. + Clobber(String), + /// The signer actor is gone or refused to sign. + Signer(String), +} + +impl std::fmt::Display for BuzzError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Config(message) => write!(f, "buzz config: {message}"), + Self::Relay(message) => write!(f, "buzz relay: {message}"), + Self::Clobber(message) => write!(f, "buzz kind-0 clobber guard: {message}"), + Self::Signer(message) => write!(f, "buzz signer: {message}"), + } + } +} + +impl std::error::Error for BuzzError {} + +/// What the clobber guard decides given the key's current kind-0 on the buzz relay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClobberDecision { + /// No kind-0 on the key yet — the first publish is safe. + FirstPublish, + /// The existing kind-0 carries our marker — it is ours from a prior run, safe to replace. + OursReplace, + /// A kind-0 exists WITHOUT our marker — refuse; publishing would clobber a foreign persona. + ForeignRefuse, +} + +/// Decide whether the node may publish its kind-0, given the marker state of the key's current +/// kind-0: `None` ⇒ no kind-0 exists; `Some(true)` ⇒ one exists carrying our marker; `Some(false)` +/// ⇒ one exists WITHOUT our marker (foreign). Pure, so the money-safe refusal is unit-testable. +pub fn clobber_decision(existing_marker: Option) -> ClobberDecision { + match existing_marker { + None => ClobberDecision::FirstPublish, + Some(true) => ClobberDecision::OursReplace, + Some(false) => ClobberDecision::ForeignRefuse, + } +} + +/// True when a fetched kind-0 event carries this node's mobee marker tag. +pub fn event_has_marker(event: &Event) -> bool { + event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().map(String::as_str) == Some(MOBEE_MARKER_TAG) + && parts.get(1).map(String::as_str) == Some(MOBEE_MARKER_VALUE) + }) +} + +/// Assemble the human-readable rate card shown in the persona's kind-0 `about`. Pure over its +/// inputs so the wording is unit-testable. `seller_rate_sats` is the `[seller]` rate used when the +/// `[buzz]` section does not set its own. +pub fn rate_card_about(cfg: &BuzzConfig, seller_rate_sats: Option) -> String { + let mut parts: Vec = Vec::new(); + if let Some(about) = cfg.about.as_deref() { + let trimmed = about.trim(); + if !trimmed.is_empty() { + parts.push(trimmed.to_owned()); + } + } + if let Some(rate) = cfg.rate_sats.or(seller_rate_sats) { + parts.push(format!("rate {rate} sat/job")); + } + if !cfg.capabilities.is_empty() { + parts.push(format!("does: {}", cfg.capabilities.join(", "))); + } + let mint = cfg.mint.as_deref().unwrap_or("testnut"); + parts.push(format!("pays via {mint}")); + parts.push("hire me on mobee".to_owned()); + parts.join(" · ") +} + +/// Build the persona metadata (kind-0 content) for the config. `name` is the display handle; the +/// rate card is the `about`. +fn persona_metadata(cfg: &BuzzConfig, seller_rate_sats: Option) -> Metadata { + Metadata::new() + .name(cfg.name.clone()) + .about(rate_card_about(cfg, seller_rate_sats)) +} + +/// A nostr-sdk signer that delegates every operation to the seller node's [`signer`](super::signer) +/// actor, so the buzz client can NIP-42 auth and publish without ever holding the seller key. Only +/// event signing + public key are supported (all the persona and the auth path need); NIP-04/44 +/// are unsupported (the persona never encrypts). +#[derive(Debug, Clone)] +pub struct NodeNostrSigner { + signer: SignerHandle, + pubkey: PublicKey, +} + +impl NodeNostrSigner { + /// Build the adapter from the node's signer handle. Fails only if the actor's cached public key + /// is not parseable (it always is — it is derived from the key at spawn). + pub fn new(signer: SignerHandle) -> Result { + let pubkey = PublicKey::parse(signer.public_key_hex()) + .map_err(|error| BuzzError::Config(format!("seller pubkey parse: {error}")))?; + Ok(Self { signer, pubkey }) + } +} + +impl NostrSigner for NodeNostrSigner { + fn backend(&self) -> SignerBackend<'_> { + SignerBackend::Custom(std::borrow::Cow::Borrowed("mobee-seller-node-signer-actor")) + } + + fn get_public_key(&self) -> BoxedFuture<'_, Result> { + let pubkey = self.pubkey; + Box::pin(async move { Ok(pubkey) }) + } + + fn sign_event(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result> { + let signer = self.signer.clone(); + Box::pin(async move { + // ALLOWLIST GATE (enumerate-entry-points doctrine): the buzz seam is the ONLY caller of + // the actor through this adapter, and it may only ever sign identity/presence/auth. Any + // other kind — a trade-path event or anything else — is refused + logged so the seam can + // never become a generic sign-anything oracle for the protocol key. + let kind = unsigned.kind.as_u16(); + if !buzz_signing_kind_allowed(kind) { + eprintln!( + "buzz signer REFUSED to sign kind-{kind}: not on the buzz allowlist {BUZZ_SIGNING_ALLOWLIST:?} \ + (only kind-0 persona, kind-{PRESENCE_KIND} presence, and kind-{NIP42_AUTH_KIND} NIP-42 auth are permitted)" + ); + return Err(SignerError::from(format!( + "buzz signing refused: kind-{kind} is not on the buzz allowlist" + ))); + } + match signer.sign_unsigned(unsigned).await { + Ok(Ok(event)) => Ok(event), + Ok(Err(message)) => Err(SignerError::from(message)), + Err(gone) => Err(SignerError::from(gone.to_string())), + } + }) + } + + fn nip04_encrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { Err(SignerError::from("nip04 unsupported for the buzz persona")) }) + } + + fn nip04_decrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _encrypted_content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { Err(SignerError::from("nip04 unsupported for the buzz persona")) }) + } + + fn nip44_encrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { Err(SignerError::from("nip44 unsupported for the buzz persona")) }) + } + + fn nip44_decrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _payload: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async move { Err(SignerError::from("nip44 unsupported for the buzz persona")) }) + } +} + +/// A live buzz persona: the connected relay client plus the presence heartbeat task. Hold it for +/// as long as the node is up; drop it (or call [`BuzzHandle::shutdown`]) to clear presence. +pub struct BuzzHandle { + client: Client, + pubkey: PublicKey, + /// The published kind-0 event id (identity/discovery — never money state). + pub kind0_event_id: String, + presence_task: tokio::task::JoinHandle<()>, + stop: tokio::sync::watch::Sender, +} + +impl BuzzHandle { + /// The persona's public key (hex) — the seller identity. + pub fn pubkey_hex(&self) -> String { + self.pubkey.to_hex() + } + + /// Stop the presence heartbeat and cleanly disconnect the relay socket. The clean disconnect is + /// what clears deployed presence immediately (rather than waiting out the ~90s TTL). + pub async fn shutdown(self) { + let _ = self.stop.send(true); + // Wake the heartbeat loop out of its sleep so it observes the stop promptly. + self.presence_task.abort(); + let _ = self.presence_task.await; + self.client.disconnect().await; + } +} + +/// Bring up the buzz persona: connect to the relay, run the kind-0 clobber guard, publish the +/// persona, and start the presence heartbeat. The seller key stays in the signer actor throughout +/// (via [`NodeNostrSigner`]). Returns a [`BuzzHandle`] that owns the live connection + heartbeat. +pub async fn start( + signer: SignerHandle, + cfg: &BuzzConfig, + seller_rate_sats: Option, +) -> Result { + let adapter = NodeNostrSigner::new(signer)?; + let pubkey = adapter.pubkey; + // Every buzz-path publish (kind-0, presence) and the NIP-42 auth all go through this ONE adapter + // — the single signing choke point where the allowlist is enforced. Keep a clone for our own + // publishes; the other is consumed by the client for auth. + let publish_signer = adapter.clone(); + + let client = Client::new(adapter); + client.automatic_authentication(true); + client + .add_relay(&cfg.relay_url) + .await + .map_err(|error| BuzzError::Relay(format!("add relay {}: {error}", cfg.relay_url)))?; + client.connect().await; + client + .wait_for_connection(Duration::from_secs(CONNECT_TIMEOUT_SECS)) + .await; + + // Clobber guard BEFORE the first publish: never overwrite a foreign kind-0 on the key. + match fetch_kind0_marker(&client, pubkey).await { + Ok(marker) => { + if let ClobberDecision::ForeignRefuse = clobber_decision(marker) { + client.disconnect().await; + return Err(BuzzError::Clobber(format!( + "a kind-0 already exists on this key ({}) that this node did not write \ + (missing the mobee marker); refusing to clobber a foreign buzz persona — \ + use a fresh key for the seller or clear the existing kind-0 first", + pubkey.to_hex() + ))); + } + } + Err(error) => { + // Fail closed: if we cannot read the current kind-0 we cannot prove we are not + // clobbering a foreign one, so refuse rather than blind-overwrite. + client.disconnect().await; + return Err(BuzzError::Clobber(format!( + "could not read the key's current kind-0 to check for a foreign persona \ + (fail-closed, refusing to publish): {error}" + ))); + } + } + + // Publish the persona kind-0 (signed via the allowlisted adapter → actor). + let kind0 = build_kind0(&publish_signer, pubkey, cfg, seller_rate_sats).await?; + let kind0_event_id = kind0.id.to_hex(); + send_event(&client, &kind0).await?; + + // First presence beat immediately, then the periodic heartbeat. + let first = build_presence(&publish_signer, pubkey).await?; + send_event(&client, &first).await?; + + let (stop, stop_rx) = tokio::sync::watch::channel(false); + let interval = Duration::from_secs(cfg.heartbeat_secs.max(1)); + let presence_task = + spawn_presence_heartbeat(client.clone(), publish_signer, pubkey, interval, stop_rx); + + Ok(BuzzHandle { + client, + pubkey, + kind0_event_id, + presence_task, + stop, + }) +} + +/// Fetch the key's current kind-0 and classify it for the clobber guard: `Ok(None)` ⇒ no kind-0, +/// `Ok(Some(true/false))` ⇒ one exists with/without our marker. +async fn fetch_kind0_marker(client: &Client, pubkey: PublicKey) -> Result, BuzzError> { + let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1); + let events = client + .fetch_events(filter, Duration::from_secs(KIND0_FETCH_TIMEOUT_SECS)) + .await + .map_err(|error| BuzzError::Relay(format!("fetch kind-0: {error}")))?; + // Newest replaceable kind-0 wins. + let newest = events.into_iter().max_by_key(|event| event.created_at); + Ok(newest.map(|event| event_has_marker(&event))) +} + +/// Build + sign (via the allowlisted adapter) the persona's kind-0 metadata event with the mobee +/// marker tag. +async fn build_kind0( + adapter: &NodeNostrSigner, + pubkey: PublicKey, + cfg: &BuzzConfig, + seller_rate_sats: Option, +) -> Result { + let metadata = persona_metadata(cfg, seller_rate_sats); + let marker = Tag::parse([MOBEE_MARKER_TAG, MOBEE_MARKER_VALUE]) + .map_err(|error| BuzzError::Config(format!("marker tag: {error}")))?; + let unsigned = EventBuilder::metadata(&metadata).tag(marker).build(pubkey); + sign_via_adapter(adapter, unsigned).await +} + +/// Build + sign (via the allowlisted adapter) one presence heartbeat event. The deployed relay +/// keys presence on the authenticated connection's pubkey and reads only the content status — no +/// tags (they are ignored). The event is signed by (and authored as) the persona itself. +async fn build_presence(adapter: &NodeNostrSigner, pubkey: PublicKey) -> Result { + let unsigned = + EventBuilder::new(Kind::Custom(PRESENCE_KIND), PRESENCE_STATUS_ONLINE).build(pubkey); + sign_via_adapter(adapter, unsigned).await +} + +/// Sign an unsigned buzz event through the allowlisted adapter (which routes to the signer actor). +/// A non-allowlisted kind is refused by the adapter before it ever reaches the actor. +async fn sign_via_adapter( + adapter: &NodeNostrSigner, + unsigned: UnsignedEvent, +) -> Result { + adapter + .sign_event(unsigned) + .await + .map_err(|error| BuzzError::Signer(error.to_string())) +} + +async fn send_event(client: &Client, event: &Event) -> Result<(), BuzzError> { + let output = client + .send_event(event) + .await + .map_err(|error| BuzzError::Relay(format!("send kind-{}: {error}", event.kind.as_u16())))?; + if output.success.is_empty() { + let failed: Vec = output + .failed + .into_iter() + .map(|(url, err)| format!("{url}: {err}")) + .collect(); + return Err(BuzzError::Relay(format!( + "no relay accepted kind-{} ({})", + event.kind.as_u16(), + failed.join("; ") + ))); + } + Ok(()) +} + +/// Spawn the presence heartbeat loop: republish an ephemeral kind-20001 `"online"` status every +/// `interval` until `stop_rx` flips true. A publish failure is logged and retried on the next tick +/// (presence recovers within the relay TTL). The task exits promptly on stop OR on abort. +fn spawn_presence_heartbeat( + client: Client, + adapter: NodeNostrSigner, + pubkey: PublicKey, + interval: Duration, + mut stop_rx: tokio::sync::watch::Receiver, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + loop { + tokio::select! { + _ = tokio::time::sleep(interval) => { + match build_presence(&adapter, pubkey).await { + Ok(event) => { + if let Err(error) = send_event(&client, &event).await { + eprintln!("buzz presence heartbeat publish failed (will retry): {error}"); + } + } + Err(error) => { + eprintln!("buzz presence heartbeat build failed (will retry): {error}"); + } + } + } + changed = stop_rx.changed() => { + // Sender dropped or told us to stop — exit the loop; the handle disconnects. + if changed.is_err() || *stop_rx.borrow() { + break; + } + } + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::home::BuzzConfig; + + fn cfg() -> BuzzConfig { + BuzzConfig { + relay_url: "wss://buzz.example".to_owned(), + name: "Rocky".to_owned(), + about: Some("Rust reviewer".to_owned()), + rate_sats: Some(50), + capabilities: vec!["code".to_owned(), "test".to_owned()], + mint: None, + heartbeat_secs: 30, + } + } + + #[test] + fn clobber_refuses_only_a_foreign_kind0() { + assert_eq!(clobber_decision(None), ClobberDecision::FirstPublish); + assert_eq!(clobber_decision(Some(true)), ClobberDecision::OursReplace); + assert_eq!(clobber_decision(Some(false)), ClobberDecision::ForeignRefuse); + } + + #[test] + fn rate_card_carries_rate_caps_and_mint() { + let about = rate_card_about(&cfg(), None); + assert!(about.contains("Rust reviewer"), "about: {about}"); + assert!(about.contains("50 sat/job"), "about: {about}"); + assert!(about.contains("code, test"), "about: {about}"); + assert!(about.contains("testnut"), "about: {about}"); + } + + #[test] + fn rate_card_falls_back_to_seller_rate() { + let mut c = cfg(); + c.rate_sats = None; + let about = rate_card_about(&c, Some(7)); + assert!(about.contains("7 sat/job"), "about: {about}"); + } + + #[test] + fn rate_card_honours_explicit_mint() { + let mut c = cfg(); + c.mint = Some("https://real.mint".to_owned()); + let about = rate_card_about(&c, None); + assert!(about.contains("https://real.mint"), "about: {about}"); + } + + #[test] + fn presence_is_in_the_ephemeral_range() { + assert!((20000..=29999).contains(&PRESENCE_KIND)); + } + + #[test] + fn allowlist_is_exactly_persona_presence_auth() { + assert!(buzz_signing_kind_allowed(0), "kind-0 persona allowed"); + assert!(buzz_signing_kind_allowed(PRESENCE_KIND), "presence allowed"); + assert!(buzz_signing_kind_allowed(NIP42_AUTH_KIND), "NIP-42 auth allowed"); + // Trade-path + arbitrary kinds are NOT signable through the buzz seam. + for kind in [3400u16, 3401, 3402, 3403, 3404, 3405, 30340, 1, 4, 9734] { + assert!(!buzz_signing_kind_allowed(kind), "kind-{kind} must be refused"); + } + } + + // TOOTH (enumerate-entry-points): the adapter is the ONLY buzz path to the actor, and it must + // sign ONLY the three allowlisted kinds. A trade-path kind requested through it is refused — + // the seam is not a sign-anything oracle for the protocol key. Signing goes through the real + // actor to prove the gate sits in front of it, not merely in a pure helper. + #[tokio::test(flavor = "current_thread")] + async fn adapter_signs_only_allowlisted_kinds() { + use nostr_sdk::prelude::{EventBuilder, Kind}; + + let root = std::env::temp_dir().join(format!( + "mobee-buzz-allowlist-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + let home = crate::home::bootstrap(&root).expect("bootstrap"); + let signer = crate::seller_node::signer::spawn(&home).expect("spawn signer"); + let adapter = NodeNostrSigner::new(signer).expect("adapter"); + let pubkey = adapter.pubkey; + + // Each allowlisted kind signs cleanly. + for kind in [0u16, PRESENCE_KIND, NIP42_AUTH_KIND] { + let unsigned = EventBuilder::new(Kind::Custom(kind), "") + .allow_self_tagging() + .build(pubkey); + let signed = adapter.sign_event(unsigned).await; + assert!(signed.is_ok(), "kind-{kind} must sign through the buzz seam: {signed:?}"); + } + + // A trade-path kind (an offer) is refused BEFORE the actor signs it. + let offer = EventBuilder::new(Kind::Custom(crate::gateway::JOB_OFFER_KIND), "") + .build(pubkey); + let refused = adapter.sign_event(offer).await; + assert!(refused.is_err(), "a trade-path kind must be refused by the buzz seam"); + assert!( + refused.unwrap_err().to_string().contains("allowlist"), + "refusal must name the allowlist" + ); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/mobee-core/src/seller_node/buzz_relay_it.rs b/crates/mobee-core/src/seller_node/buzz_relay_it.rs new file mode 100644 index 00000000..529bc94f --- /dev/null +++ b/crates/mobee-core/src/seller_node/buzz_relay_it.rs @@ -0,0 +1,399 @@ +//! LOCAL-RELAY integration tests for the buzz persona: drive [`SellerNode::start_buzz`] end-to-end +//! against an in-process NIP-01 relay (`nostr-relay-builder`) and assert on RELAY TRAFFIC — the +//! kind-0 persona is published + fetchable with its rate card, the clobber guard refuses a foreign +//! kind-0, and presence heartbeats flow while up and stop on clean shutdown. +//! +//! What a plain NIP-01 fixture CAN prove here: the publish/clobber/heartbeat LOGIC. What it can NOT +//! reproduce is the deployed relay's WS + Redis-TTL presence layer (`PRESENCE_SNAPSHOT` REQ, ~90s +//! expiry) — that is a stored-nowhere runtime behaviour of buzzrelay, verified live against the +//! deployed relay (acceptance item 2). Here presence is asserted as: heartbeats are published while +//! the node is up, and stop after a clean shutdown. + +use super::*; +use crate::home::{self, BuzzConfig}; +use crate::seller_node::buzz::{event_has_marker, BuzzError, MOBEE_MARKER_TAG, MOBEE_MARKER_VALUE, PRESENCE_KIND}; +use nostr_relay_builder::prelude::{LocalRelay, RelayBuilder}; +use nostr_sdk::prelude::{ + Client, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification, Tag, +}; +use nostr_sdk::JsonUtil; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +static IT_SEQ: AtomicU64 = AtomicU64::new(0); + +fn unique_root(label: &str) -> std::path::PathBuf { + let n = IT_SEQ.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!("mobee-buzz-it-{label}-{}-{n}", std::process::id())) +} + +async fn start_relay() -> (LocalRelay, String) { + let relay = LocalRelay::new(RelayBuilder::default()); + relay.run().await.expect("relay run"); + let url = relay.url().await.to_string(); + (relay, url) +} + +async fn connect_client(relay_url: &str) -> Client { + let client = Client::new(Keys::generate()); + client.add_relay(relay_url).await.expect("add relay"); + client.connect().await; + client.wait_for_connection(Duration::from_secs(5)).await; + client +} + +/// A reader bound to an ADMITTED key with NIP-42 auto-auth — the deployed relay gates reads, so a +/// live observer must authenticate as an admitted member (a random unadmitted key reads nothing). +async fn connect_admitted_observer(relay_url: &str, secret_hex: &str) -> Client { + let keys = Keys::parse(secret_hex).expect("observer keys"); + let client = Client::new(keys); + client.automatic_authentication(true); + client.add_relay(relay_url).await.expect("add relay"); + client.connect().await; + client.wait_for_connection(Duration::from_secs(10)).await; + client +} + +/// Bootstrap a seller home wired with a `[buzz]` persona bound to `relay_url` (fast 1s heartbeat). +fn buzz_home(root: &std::path::Path, relay_url: &str) -> home::MobeeHome { + let mut h = home::bootstrap(root).expect("bootstrap home"); + h.config.buzz = Some(BuzzConfig { + relay_url: relay_url.to_string(), + name: "Rocky".to_string(), + about: Some("Rust reviewer".to_string()), + rate_sats: Some(50), + capabilities: vec!["code".to_string(), "test".to_string()], + mint: None, + heartbeat_secs: 1, + }); + h +} + +async fn fetch_kind0_about(observer: &Client, pubkey: PublicKey) -> Option { + let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1); + let events = observer + .fetch_events(filter, Duration::from_secs(5)) + .await + .expect("fetch kind-0"); + let newest = events.into_iter().max_by_key(|e| e.created_at)?; + let metadata = Metadata::from_json(&newest.content).ok()?; + metadata.about +} + +/// Count of ephemeral presence events (kind PRESENCE_KIND) observed from `author`, collected before +/// the node starts so none is missed. +fn spawn_presence_collector(client: &Client, author: PublicKey) -> Arc> { + let count = Arc::new(Mutex::new(0usize)); + let sink = count.clone(); + let mut notif = client.notifications(); + tokio::spawn(async move { + while let Ok(n) = notif.recv().await { + if let RelayPoolNotification::Event { event, .. } = n { + if event.kind.as_u16() == PRESENCE_KIND && event.pubkey == author { + *sink.lock().unwrap_or_else(|e| e.into_inner()) += 1; + } + } + } + }); + count +} + +async fn wait_until bool>(timeout: Duration, mut cond: F) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + loop { + if cond() { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +// ── Item 1 (logic): node boot publishes a kind-0 fetchable by pubkey carrying the rate card. ── +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn boot_publishes_kind0_rate_card() { + let (relay, relay_url) = start_relay().await; + let home = buzz_home(&unique_root("kind0"), &relay_url); + let node = SellerNode::open(home).await.expect("open node"); + let seller_pk = PublicKey::parse(node.seller_pubkey()).expect("seller pubkey"); + + let observer = connect_client(&relay_url).await; + + let handle = node + .start_buzz() + .await + .expect("start buzz") + .expect("buzz configured"); + + // The persona kind-0 is fetchable by pubkey and its about carries the rate card. + let about = fetch_kind0_about(&observer, seller_pk).await.expect("kind-0 present"); + assert!(about.contains("50 sat/job"), "rate missing from about: {about}"); + assert!(about.contains("code, test"), "capabilities missing: {about}"); + assert!(about.contains("testnut"), "mint missing: {about}"); + assert!(about.contains("Rust reviewer"), "blurb missing: {about}"); + + handle.shutdown().await; + relay.shutdown(); +} + +// ── Item 3: a pre-existing FOREIGN kind-0 on the key ⇒ start_buzz refuses (no clobber). ── +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn foreign_kind0_refuses_clobber() { + let (relay, relay_url) = start_relay().await; + let root = unique_root("foreign"); + let home = buzz_home(&root, &relay_url); + // Seed a FOREIGN kind-0 (no mobee marker) on the seller's own key, as if the key were already a + // buzz inhabitant published by something else. + let secret = home::read_secret_key_hex(&home).expect("secret"); + let keys = Keys::parse(&secret).expect("keys"); + let seeder = connect_client(&relay_url).await; + let foreign = EventBuilder::metadata(&Metadata::new().name("someone-else").about("not mobee")) + .sign_with_keys(&keys) + .expect("sign foreign kind-0"); + seeder.send_event(&foreign).await.expect("seed foreign kind-0"); + + let node = SellerNode::open(home).await.expect("open node"); + let result = node.start_buzz().await; + match result { + Err(BuzzError::Clobber(message)) => { + assert!(message.contains("foreign") || message.contains("did not write"), "msg: {message}"); + } + Err(other) => panic!("expected a clobber refusal, got a different error: {other}"), + Ok(_) => panic!("expected a clobber refusal, but start_buzz succeeded"), + } + relay.shutdown(); +} + +// ── OUR OWN prior kind-0 (carries the marker) ⇒ start_buzz replaces it, no refusal. ── +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn own_marked_kind0_is_replaced() { + let (relay, relay_url) = start_relay().await; + let root = unique_root("ours"); + let home = buzz_home(&root, &relay_url); + let secret = home::read_secret_key_hex(&home).expect("secret"); + let keys = Keys::parse(&secret).expect("keys"); + let seeder = connect_client(&relay_url).await; + // Seed a kind-0 WITH our marker (a prior run's persona). + let marker = Tag::parse([MOBEE_MARKER_TAG, MOBEE_MARKER_VALUE]).expect("marker tag"); + let ours = EventBuilder::metadata(&Metadata::new().name("Rocky").about("old card")) + .tag(marker) + .sign_with_keys(&keys) + .expect("sign own kind-0"); + assert!(event_has_marker(&ours), "seeded event must carry the marker"); + seeder.send_event(&ours).await.expect("seed own kind-0"); + + let node = SellerNode::open(home).await.expect("open node"); + let handle = node + .start_buzz() + .await + .expect("start buzz over our own prior kind-0") + .expect("buzz configured"); + handle.shutdown().await; + relay.shutdown(); +} + +// ── LIVE deployed-relay harness (item 1): kind-0 round-trips against buzzrelay. ── +// +// Ignored by default (needs relay admission for the key). Run once the throwaway pubkey is admitted: +// BUZZ_PERSONA_SECRET=<64-hex secret> \ +// BUZZ_LIVE_RELAY=wss://buzzrelay.orveth.dev \ +// cargo test -p mobee-core --no-default-features --features gateway,git-delivery,wallet --release \ +// -- --ignored --nocapture live_buzz_kind0_round_trip_against_deployed_relay +// +// It boots a node whose identity IS the throwaway key, publishes the persona, and fetches the +// kind-0 back from the deployed relay by pubkey, asserting the rate card. Presence (items 2-3) is +// confirmed separately against the deployed relay's PRESENCE_SNAPSHOT once its exact wire shape is +// pinned with the buzz keeper. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "needs relay admission for the throwaway key; run explicitly with env"] +async fn live_buzz_kind0_round_trip_against_deployed_relay() { + let secret = match std::env::var("BUZZ_PERSONA_SECRET") { + Ok(value) if value.trim().len() == 64 => value.trim().to_string(), + _ => panic!("set BUZZ_PERSONA_SECRET to a 64-hex throwaway secret"), + }; + let relay_url = std::env::var("BUZZ_LIVE_RELAY") + .unwrap_or_else(|_| "wss://buzzrelay.orveth.dev".to_string()); + + let root = unique_root("live"); + let home = { + let mut h = buzz_home(&root, &relay_url); + // Adopt the throwaway identity as the node's key. + std::fs::write(&h.key_path, &secret).expect("write throwaway key"); + h.config.buzz.as_mut().unwrap().heartbeat_secs = 30; + h + }; + let keys = Keys::parse(&secret).expect("keys"); + let seller_pk = keys.public_key(); + + let node = SellerNode::open(home).await.expect("open node"); + assert_eq!(node.seller_pubkey(), seller_pk.to_hex(), "node identity is the throwaway key"); + let handle = node + .start_buzz() + .await + .expect("start buzz against the deployed relay") + .expect("buzz configured"); + eprintln!("published kind-0 id={} pubkey={}", handle.kind0_event_id, seller_pk.to_hex()); + + // The deployed relay gates reads behind NIP-42 — the observer authenticates as an admitted key. + let observer_secret = std::env::var("BUZZ_OBSERVER_SECRET") + .expect("set BUZZ_OBSERVER_SECRET to a 64-hex admitted observer secret"); + let observer = connect_admitted_observer(&relay_url, &observer_secret).await; + let about = fetch_kind0_about(&observer, seller_pk) + .await + .expect("kind-0 fetchable from the deployed relay by pubkey"); + eprintln!("fetched kind-0 about: {about}"); + assert!(about.contains("sat/job"), "rate card missing from live kind-0: {about}"); + + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&root); +} + +// ── Presence: heartbeats flow while up, and STOP after a clean shutdown. ── +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn presence_heartbeats_flow_then_stop_on_shutdown() { + let (relay, relay_url) = start_relay().await; + let home = buzz_home(&unique_root("presence"), &relay_url); + let node = SellerNode::open(home).await.expect("open node"); + let seller_pk = PublicKey::parse(node.seller_pubkey()).expect("seller pubkey"); + + // Observer subscribes to the seller's presence BEFORE the node starts. + let observer = connect_client(&relay_url).await; + observer + .subscribe( + Filter::new().kind(Kind::Custom(PRESENCE_KIND)).author(seller_pk), + None, + ) + .await + .expect("subscribe presence"); + let count = spawn_presence_collector(&observer, seller_pk); + + let handle = node + .start_buzz() + .await + .expect("start buzz") + .expect("buzz configured"); + + // At least two beats arrive within a few seconds (1s cadence + the immediate first beat). + let flowed = wait_until(Duration::from_secs(6), || { + *count.lock().unwrap_or_else(|e| e.into_inner()) >= 2 + }) + .await; + assert!(flowed, "presence heartbeats must flow while the node is up"); + + // Clean shutdown stops the heartbeat: after disconnecting, the count stops growing. + handle.shutdown().await; + let after = *count.lock().unwrap_or_else(|e| e.into_inner()); + tokio::time::sleep(Duration::from_secs(3)).await; + let later = *count.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!(after, later, "no presence beats must be published after a clean shutdown"); + + relay.shutdown(); +} + +// ── LIVE deployed-relay presence (items 2-3): presence up/clear against buzzrelay. ── +// +// Ignored by default (needs BOTH demo keys admitted). Run once admitted: +// BUZZ_PERSONA_SECRET= \ +// BUZZ_OBSERVER_SECRET= \ +// BUZZ_LIVE_RELAY=wss://buzzrelay.orveth.dev \ +// cargo test -p mobee-core --no-default-features --features gateway,git-delivery,wallet --release \ +// -- --ignored --nocapture live_buzz_presence_against_deployed_relay +// +// Deployed presence (relay source, keeper:mobee-buzz): a kind-20001 `"online"` from the AUTHED +// connection registers presence keyed on the authed pubkey; the relay ignores tags and expires it +// on a ~60s TTL. The canonical operator snapshot is the HTTP `POST /query` (NIP-98) which +// synthesizes a RELAY-SIGNED online event — a WS `REQ` for a kind-20001 hits the DB (ephemerals are +// never stored) and always returns empty. This test uses the equivalent, simpler proof the keeper +// named: a live WS SUB `{kinds:[20001], authors:[persona]}` opened BEFORE a beat receives the raw +// SELF-signed heartbeat ephemeral each cycle — so it asserts presence FLOWS while up and STOPS on a +// clean shutdown. (`authors`, not `#p`: a `#p` filter has authors=None and falls through to the +// empty DB query.) +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "needs both demo keys admitted; run explicitly with env"] +async fn live_buzz_presence_against_deployed_relay() { + let secret = match std::env::var("BUZZ_PERSONA_SECRET") { + Ok(value) if value.trim().len() == 64 => value.trim().to_string(), + _ => panic!("set BUZZ_PERSONA_SECRET to a 64-hex throwaway persona secret"), + }; + let observer_secret = match std::env::var("BUZZ_OBSERVER_SECRET") { + Ok(value) if value.trim().len() == 64 => value.trim().to_string(), + _ => panic!("set BUZZ_OBSERVER_SECRET to a 64-hex admitted observer secret"), + }; + let relay_url = std::env::var("BUZZ_LIVE_RELAY") + .unwrap_or_else(|_| "wss://buzzrelay.orveth.dev".to_string()); + + let root = unique_root("live-presence"); + let home = { + let mut h = buzz_home(&root, &relay_url); + std::fs::write(&h.key_path, &secret).expect("write throwaway key"); + // Fast beat so a heartbeat lands inside the test window. + h.config.buzz.as_mut().unwrap().heartbeat_secs = 3; + h + }; + let seller_pk = Keys::parse(&secret).expect("keys").public_key(); + + let node = SellerNode::open(home).await.expect("open node"); + + // Admitted observer subscribes to the persona's presence BEFORE the node starts, so it catches + // the live self-signed heartbeat ephemerals. + let observer = connect_admitted_observer(&relay_url, &observer_secret).await; + observer + .subscribe( + Filter::new().kind(Kind::Custom(PRESENCE_KIND)).author(seller_pk), + None, + ) + .await + .expect("subscribe presence"); + let beats = spawn_presence_status_collector(&observer, seller_pk); + + let handle = node.start_buzz().await.expect("start buzz").expect("buzz configured"); + + // Item 2 (online while up): at least one `"online"` heartbeat ephemeral flows. + let flowed = wait_until(Duration::from_secs(12), || { + !beats.lock().unwrap_or_else(|e| e.into_inner()).is_empty() + }) + .await; + assert!(flowed, "a live presence heartbeat must flow while the node is up"); + { + let seen = beats.lock().unwrap_or_else(|e| e.into_inner()); + eprintln!("live presence beats while up: {} (first content={:?})", seen.len(), seen.first()); + assert!( + seen.iter().any(|c| c == "online"), + "presence heartbeat content must be the bare \"online\" status: {seen:?}" + ); + } + + // Item 3 (clean disconnect stops presence): after shutdown, no further beats arrive. + handle.shutdown().await; + let at_shutdown = beats.lock().unwrap_or_else(|e| e.into_inner()).len(); + tokio::time::sleep(Duration::from_secs(8)).await; + let later = beats.lock().unwrap_or_else(|e| e.into_inner()).len(); + eprintln!("presence beats at shutdown={at_shutdown}, after 8s={later}"); + assert_eq!( + at_shutdown, later, + "no presence beats may flow after a clean shutdown (heartbeat stopped + WS disconnected)" + ); + + observer.disconnect().await; + let _ = std::fs::remove_dir_all(&root); +} + +/// Collect the CONTENT of each kind-20001 presence event observed from `author`. +fn spawn_presence_status_collector(client: &Client, author: PublicKey) -> Arc>> { + let seen = Arc::new(Mutex::new(Vec::::new())); + let sink = seen.clone(); + let mut notif = client.notifications(); + tokio::spawn(async move { + while let Ok(n) = notif.recv().await { + if let RelayPoolNotification::Event { event, .. } = n { + if event.kind.as_u16() == PRESENCE_KIND && event.pubkey == author { + sink.lock().unwrap_or_else(|e| e.into_inner()).push(event.content.clone()); + } + } + } + }); + seen +} diff --git a/crates/mobee-core/src/seller_node/mod.rs b/crates/mobee-core/src/seller_node/mod.rs index af7acc00..6d9da45c 100644 --- a/crates/mobee-core/src/seller_node/mod.rs +++ b/crates/mobee-core/src/seller_node/mod.rs @@ -18,6 +18,7 @@ //! receiving wallet (owned by the [`wallet_actor`]). This mirrors the buyer daemon's shape; a shared //! node core is deferred until both consumers exist (issue #131). +pub mod buzz; pub mod ingester; pub mod lock; pub mod outbox; @@ -160,7 +161,12 @@ impl SellerNode { &self.store } - /// The serialized signer actor (owns the seller key). + /// The serialized signer actor (owns the seller key). Exposing the handle is safe: the actor is + /// DEFAULT-DENY on its raw [`signer::SignerHandle::sign_unsigned`] path (only the + /// persona/presence/NIP-42-auth allowlist — [`signer::UNSIGNED_SIGN_ALLOWLIST`] — signs there; + /// every other kind is refused inside the actor), so a holder of the handle cannot sign a + /// trade-path event through it. The generic draft path ([`signer::SignerHandle::sign`]) is the + /// outbox's designed protocol-signing path and is unchanged. pub fn signer(&self) -> &SignerHandle { &self.signer } @@ -184,6 +190,19 @@ impl SellerNode { }) } + /// Bring up the node's buzz persona if `[buzz]` is configured, returning a live + /// [`buzz::BuzzHandle`] (connected relay + presence heartbeat) to hold for the node's lifetime. + /// With no `[buzz]` section this is inert — `Ok(None)`, no connection, no publish. The persona + /// is signed by the node's existing signer actor (one identity; the key never leaves it). + pub async fn start_buzz(&self) -> Result, buzz::BuzzError> { + let Some(cfg) = self.home.config.buzz.as_ref() else { + return Ok(None); + }; + let seller_rate_sats = self.home.config.seller.as_ref().map(|seller| seller.rate_sats); + let handle = buzz::start(self.signer.clone(), cfg, seller_rate_sats).await?; + Ok(Some(handle)) + } + /// A status snapshot proving the boundary end to end — the store answered, and the wallet actor /// answered through its queue. The secret key is never included. pub async fn status_snapshot(&self) -> Result { @@ -201,6 +220,9 @@ impl SellerNode { } } +#[cfg(test)] +mod buzz_relay_it; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/mobee-core/src/seller_node/signer.rs b/crates/mobee-core/src/seller_node/signer.rs index c857c241..1c8df4d4 100644 --- a/crates/mobee-core/src/seller_node/signer.rs +++ b/crates/mobee-core/src/seller_node/signer.rs @@ -7,7 +7,7 @@ //! second it passes makes the resulting event id deterministic, so a re-publish after a crash is //! idempotent at the relay. -use nostr_sdk::{JsonUtil, Keys, Timestamp}; +use nostr_sdk::{Event, JsonUtil, Keys, Timestamp, UnsignedEvent}; use tokio::sync::{mpsc, oneshot}; use crate::gateway::{self, EventDraft}; @@ -31,10 +31,19 @@ enum Command { created_at: i64, reply: oneshot::Sender>, }, + /// Sign an already-built [`UnsignedEvent`] as-is (its own `created_at`/tags/content). This is + /// the path the buzz persona uses for kind-0 + presence and the one the nostr-sdk client uses + /// for NIP-42 auth challenges, so the seller key stays owned by this actor rather than being + /// handed to a client. The unsigned event's author MUST be this actor's key (the client and + /// the persona both build it from the actor's public key); a mismatch is refused. + SignUnsigned { + unsigned: UnsignedEvent, + reply: oneshot::Sender>, + }, } /// A cheap, cloneable handle to the signer actor. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct SignerHandle { tx: mpsc::Sender, /// Cached once at spawn so the common read need not round-trip. @@ -79,6 +88,21 @@ impl SignerHandle { rx.await.map_err(|_| SignerActorGone) } + /// Sign an already-built [`UnsignedEvent`] through the serialized signer. Used by the buzz + /// persona (kind-0 + presence) and by the NIP-42 auth path, so the seller key never leaves the + /// actor. The event's author must be this actor's key. + pub async fn sign_unsigned( + &self, + unsigned: UnsignedEvent, + ) -> Result, SignerActorGone> { + let (reply, rx) = oneshot::channel(); + self.tx + .send(Command::SignUnsigned { unsigned, reply }) + .await + .map_err(|_| SignerActorGone)?; + rx.await.map_err(|_| SignerActorGone) + } + /// The seller public key (hex), routed through the actor queue (proves the serialized path). pub async fn public_key_via_actor(&self) -> Result { let (reply, rx) = oneshot::channel(); @@ -114,6 +138,10 @@ pub fn spawn(home: &MobeeHome) -> Result { let result = sign_event(&keys, &draft, created_at); let _ = reply.send(result); } + Command::SignUnsigned { unsigned, reply } => { + let result = sign_unsigned_event(&keys, unsigned); + let _ = reply.send(result); + } } } }); @@ -124,6 +152,49 @@ pub fn spawn(home: &MobeeHome) -> Result { }) } +/// The EXACT event kinds the raw [`SignerHandle::sign_unsigned`] path may sign — kind-0 persona, +/// kind-20001 presence, and the kind-22242 NIP-42 auth challenge. This is the buzz seam's signing +/// surface (see [`crate::seller_node::buzz`]); the trade path signs its own kinds through the +/// separate draft-signing [`SignerHandle::sign`], not this one. +/// +/// The allowlist is enforced **inside the actor** ([`sign_unsigned_event`]) so this raw-signing +/// capability is DEFAULT-DENY for every caller — not merely at the buzz wrapper. It can never +/// become a sign-anything oracle for the seller's protocol key regardless of who holds the handle +/// (enumerate-entry-points doctrine). The wrapper keeps its own check as belt-and-suspenders. +pub const UNSIGNED_SIGN_ALLOWLIST: [u16; 3] = [0, 20_001, 22_242]; + +/// True iff `kind` is on the raw-sign allowlist ([`UNSIGNED_SIGN_ALLOWLIST`]). +pub fn unsigned_sign_kind_allowed(kind: u16) -> bool { + UNSIGNED_SIGN_ALLOWLIST.contains(&kind) +} + +/// Sign an already-built [`UnsignedEvent`] as-is. Two fail-closed gates, both inside the actor: +/// (1) the author MUST be this actor's key (the actor only signs its own identity), and (2) the +/// kind MUST be on [`UNSIGNED_SIGN_ALLOWLIST`] (persona / presence / NIP-42 auth) — every other +/// kind is refused + logged, so no caller can turn this raw-sign path into a sign-anything oracle +/// for the protocol key. +fn sign_unsigned_event(keys: &Keys, unsigned: UnsignedEvent) -> Result { + if unsigned.pubkey != keys.public_key() { + return Err(format!( + "refusing to sign an event authored by {} (signer identity is {})", + unsigned.pubkey.to_hex(), + keys.public_key().to_hex() + )); + } + let kind = unsigned.kind.as_u16(); + if !unsigned_sign_kind_allowed(kind) { + eprintln!( + "signer actor REFUSED raw-sign of kind-{kind}: not on the raw-sign allowlist \ + {UNSIGNED_SIGN_ALLOWLIST:?} (only persona/presence/NIP-42-auth); trade-path kinds sign \ + through the draft path, not this one" + ); + return Err(format!( + "signer refused raw-sign: kind-{kind} is not on the raw-sign allowlist" + )); + } + unsigned.sign_with_keys(keys).map_err(|error| error.to_string()) +} + fn sign_event(keys: &Keys, draft: &EventDraft, created_at: i64) -> Result { // Reuse the canonical draft→builder conversion so the tags (version, namespace, routing) are // applied exactly as the rest of the protocol builds them — no hand-rolled tag handling. @@ -192,6 +263,73 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + // The actor signs its OWN unsigned events (kind-0 / presence / NIP-42 auth path) but REFUSES an + // event authored by a different key — the actor only ever attributes signatures to its identity. + #[tokio::test(flavor = "current_thread")] + async fn sign_unsigned_signs_own_and_refuses_foreign_author() { + use nostr_sdk::prelude::{EventBuilder, Kind, PublicKey}; + + let root = temp_home("sign-unsigned"); + let _ = std::fs::remove_dir_all(&root); + let home = bootstrap(&root).expect("bootstrap"); + let signer = spawn(&home).expect("spawn"); + let own = PublicKey::parse(signer.public_key_hex()).expect("own pubkey"); + + // Own-authored kind-0 signs cleanly and preserves the fields. + let mine = EventBuilder::new(Kind::Metadata, "{}").build(own); + let signed = signer.sign_unsigned(mine).await.expect("actor").expect("sign own"); + assert_eq!(signed.pubkey, own); + assert_eq!(signed.kind, Kind::Metadata); + + // An event authored by a DIFFERENT key is refused (never signed under our identity). + let foreign = Keys::generate().public_key(); + let theirs = EventBuilder::new(Kind::Metadata, "{}").build(foreign); + let refused = signer.sign_unsigned(theirs).await.expect("actor"); + assert!(refused.is_err(), "a foreign-authored event must be refused"); + let _ = std::fs::remove_dir_all(&root); + } + + // TOOTH (default-deny at the actor — the gate's exact probe shape): a DIRECT + // `sign_unsigned(kind-3402)` with NO buzz adapter is REFUSED by the actor. This is the proven + // defect class (a pub `SignerHandle` could otherwise get a trade kind signed); enforcement sits + // inside the actor, not only at the wrapper. RED-ON-REVERT: delete the kind check in + // `sign_unsigned_event` and this goes green-signs → the assertion fails. + #[tokio::test(flavor = "current_thread")] + async fn sign_unsigned_refuses_non_allowlisted_kind_directly() { + use nostr_sdk::prelude::{EventBuilder, Kind, PublicKey}; + + let root = temp_home("raw-allowlist"); + let _ = std::fs::remove_dir_all(&root); + let home = bootstrap(&root).expect("bootstrap"); + let signer = spawn(&home).expect("spawn"); + let own = PublicKey::parse(signer.public_key_hex()).expect("own pubkey"); + + // Each allowlisted kind (persona / presence / NIP-42 auth) signs. + for kind in UNSIGNED_SIGN_ALLOWLIST { + let unsigned = EventBuilder::new(Kind::Custom(kind), "").build(own); + assert!( + signer.sign_unsigned(unsigned).await.expect("actor").is_ok(), + "allowlisted kind-{kind} must sign" + ); + } + + // The gate probe: a self-authored trade kind (3402 CLAIM) called DIRECTLY on the handle, + // no adapter, is refused by the ACTOR. Every trade-path kind is refused the same way. + for trade_kind in [3400u16, 3401, 3402, 3403, 3404, 3405, 30340] { + let event = EventBuilder::new(Kind::Custom(trade_kind), "").build(own); + let refused = signer.sign_unsigned(event).await.expect("actor"); + assert!( + refused.is_err(), + "the actor must refuse non-allowlisted kind-{trade_kind} even when self-authored and called directly" + ); + assert!( + refused.unwrap_err().contains("allowlist"), + "refusal names the allowlist" + ); + } + let _ = std::fs::remove_dir_all(&root); + } + // A signed event carries the protocol tags a live buyer requires (`parse_offer` rejects an event // without `["v","0"]` / `["t","mobee"]`). Proves the outbox→signer path emits wire-valid events. #[tokio::test(flavor = "current_thread")]