diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index d7811719..f906b5b7 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -2198,6 +2198,16 @@ fn build_mcp_servers(config: &Config) -> Vec { value: token.clone(), }); } + // Forward SPROUT_TOOLSETS so the MCP server enables the + // same toolsets the operator configured for this harness. + if let Ok(ts) = std::env::var("SPROUT_TOOLSETS") { + if !ts.is_empty() { + env.push(EnvVar { + name: "SPROUT_TOOLSETS".into(), + value: ts, + }); + } + } env }, }] diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs index c56b23f1..b5167e79 100644 --- a/crates/sprout-cli/src/commands/mod.rs +++ b/crates/sprout-cli/src/commands/mod.rs @@ -4,5 +4,6 @@ pub mod dms; pub mod feed; pub mod messages; pub mod reactions; +pub mod social; pub mod users; pub mod workflows; diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs new file mode 100644 index 00000000..607be4b6 --- /dev/null +++ b/crates/sprout-cli/src/commands/social.rs @@ -0,0 +1,131 @@ +use nostr::EventId; +use serde::Deserialize; + +use crate::client::SproutClient; +use crate::error::CliError; +use crate::validate::validate_hex64; + +/// Each command module defines its own `require_keys!` macro. +macro_rules! require_keys { + ($client:expr) => { + $client.keys().ok_or_else(|| { + CliError::Key( + "private key required for write operations (set SPROUT_PRIVATE_KEY)".into(), + ) + })? + }; +} + +/// Per-module helper (same pattern as messages.rs). +fn parse_event_id(hex: &str) -> Result { + EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}"))) +} + +/// A single contact entry (CLI-local, not from sprout-sdk). +#[derive(Debug, Deserialize)] +pub struct ContactEntry { + pub pubkey: String, + #[serde(default)] + pub relay_url: Option, + #[serde(default)] + pub petname: Option, +} + +pub async fn cmd_publish_note( + client: &SproutClient, + content: &str, + reply_to: Option<&str>, +) -> Result<(), CliError> { + if let Some(r) = reply_to { + validate_hex64(r)?; + } + + let keys = require_keys!(client); + let reply_id = reply_to.map(parse_event_id).transpose()?; + + let builder = sprout_sdk::build_note(content, reply_id) + .map_err(|e| CliError::Other(format!("build error: {e}")))?; + + let event = builder + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + +pub async fn cmd_set_contact_list( + client: &SproutClient, + contacts_json: &str, +) -> Result<(), CliError> { + let keys = require_keys!(client); + let entries: Vec = serde_json::from_str(contacts_json) + .map_err(|e| CliError::Usage(format!("invalid contacts JSON: {e}")))?; + + let contacts: Vec<(&str, Option<&str>, Option<&str>)> = entries + .iter() + .map(|c| { + ( + c.pubkey.as_str(), + c.relay_url.as_deref(), + c.petname.as_deref(), + ) + }) + .collect(); + + let builder = sprout_sdk::build_contact_list(&contacts) + .map_err(|e| CliError::Other(format!("build error: {e}")))?; + + let event = builder + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + +pub async fn cmd_get_event(client: &SproutClient, event_id: &str) -> Result<(), CliError> { + validate_hex64(event_id)?; + let path = format!("/api/events/{event_id}"); + client.run_get(&path).await +} + +pub async fn cmd_get_user_notes( + client: &SproutClient, + pubkey: &str, + limit: Option, + before: Option, + before_id: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(pubkey)?; + if let Some(bid) = before_id { + validate_hex64(bid)?; + } + if before_id.is_some() && before.is_none() { + return Err(CliError::Usage("before_id requires before".to_string())); + } + let mut path = format!("/api/users/{pubkey}/notes"); + let mut params = vec![]; + if let Some(l) = limit { + params.push(format!("limit={l}")); + } + if let Some(b) = before { + params.push(format!("before={b}")); + } + if let Some(bid) = before_id { + params.push(format!("before_id={bid}")); + } + if !params.is_empty() { + path.push('?'); + path.push_str(¶ms.join("&")); + } + client.run_get(&path).await +} + +pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let path = format!("/api/users/{pubkey}/contact-list"); + client.run_get(&path).await +} diff --git a/crates/sprout-cli/src/main.rs b/crates/sprout-cli/src/main.rs index 69b1b5e5..120b8dc0 100644 --- a/crates/sprout-cli/src/main.rs +++ b/crates/sprout-cli/src/main.rs @@ -397,6 +397,62 @@ enum Cmd { }, /// Delete all your API tokens DeleteAllTokens, + + // Social + /// Publish a short text note (kind:1) to the global feed. + #[command(name = "publish-note")] + PublishNote { + /// Text content of the note. + #[arg(long)] + content: String, + /// 64-char hex event ID to reply to. + #[arg(long)] + reply_to: Option, + }, + + /// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. + #[command(name = "set-contact-list")] + SetContactList { + /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}] + #[arg(long)] + contacts: String, + }, + + /// Get a single event by event ID (notes, profiles, contacts, articles, channel events). + #[command(name = "get-event")] + GetEvent { + /// 64-char hex event ID. + #[arg(long)] + event: String, + }, + + /// List kind:1 text notes by a specific user. + #[command(name = "get-user-notes")] + GetUserNotes { + /// 64-char hex pubkey of the author. + #[arg(long)] + pubkey: String, + /// Maximum number of notes to return (default 50, max 100). + #[arg(long)] + limit: Option, + /// Unix timestamp cursor — return notes created before this time. + /// Use with --before-id for stable composite cursor pagination. + #[arg(long)] + before: Option, + /// Hex event ID cursor for composite keyset pagination. Use together with + /// --before to avoid skipping same-second events. Pass the before_id value + /// from the previous page's next_cursor response. + #[arg(long)] + before_id: Option, + }, + + /// Get a user's contact/follow list (kind:3) by hex pubkey. + #[command(name = "get-contact-list")] + GetContactList { + /// 64-char hex pubkey. + #[arg(long)] + pubkey: String, + }, } // --------------------------------------------------------------------------- @@ -728,6 +784,33 @@ async fn run(cli: Cli) -> Result<(), CliError> { types, } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await, + // ---- Social -------------------------------------------------------- + Cmd::PublishNote { content, reply_to } => { + commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await + } + Cmd::SetContactList { contacts } => { + commands::social::cmd_set_contact_list(&client, &contacts).await + } + Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await, + Cmd::GetUserNotes { + pubkey, + limit, + before, + before_id, + } => { + commands::social::cmd_get_user_notes( + &client, + &pubkey, + limit, + before, + before_id.as_deref(), + ) + .await + } + Cmd::GetContactList { pubkey } => { + commands::social::cmd_get_contact_list(&client, &pubkey).await + } + // ---- Auth & Tokens ------------------------------------------------- Cmd::Auth => unreachable!("handled above"), Cmd::ListTokens => commands::auth::cmd_list_tokens(&client).await, @@ -779,10 +862,10 @@ mod tests { assert!(super::parse_bool_flag("--approved", "").is_err()); } - /// Parity: the CLI exposes exactly the expected 48 commands. + /// Parity: the CLI exposes exactly the expected 53 commands. /// If a command is added or removed, this test forces a conscious update. #[test] - fn command_inventory_is_48() { + fn command_inventory_is_53() { let expected: Vec<&str> = vec![ "add-channel-member", "add-dm-member", @@ -800,11 +883,14 @@ mod tests { "edit-message", "get-canvas", "get-channel", + "get-contact-list", + "get-event", "get-feed", "get-messages", "get-presence", "get-reactions", "get-thread", + "get-user-notes", "get-users", "get-workflow", "get-workflow-runs", @@ -816,6 +902,7 @@ mod tests { "list-tokens", "list-workflows", "open-dm", + "publish-note", "remove-channel-member", "remove-reaction", "search", @@ -825,6 +912,7 @@ mod tests { "set-channel-add-policy", "set-channel-purpose", "set-channel-topic", + "set-contact-list", "set-presence", "set-profile", "trigger-workflow", @@ -844,8 +932,8 @@ mod tests { assert_eq!( actual.len(), - 48, - "Expected 48 commands, got {}. Actual: {:?}", + 53, + "Expected 53 commands, got {}. Actual: {:?}", actual.len(), actual ); diff --git a/crates/sprout-db/src/event.rs b/crates/sprout-db/src/event.rs index 0559d7b0..b06b2111 100644 --- a/crates/sprout-db/src/event.rs +++ b/crates/sprout-db/src/event.rs @@ -37,6 +37,17 @@ pub struct EventQuery { /// Restrict to events with this exact `d_tag` value (NIP-33). /// Pushed into SQL via the `idx_events_parameterized` index. pub d_tag: Option, + /// Composite keyset cursor: exclude events at or "after" this (created_at, id) pair. + /// Used with `until` for stable pagination: events where + /// `created_at < until OR (created_at = until AND id > before_id)`. + /// When set, `until` must also be set. + pub before_id: Option>, + /// When true, restricts results to global events (`channel_id IS NULL`). + /// Use for endpoints that serve non-channel data (e.g. kind:1 notes) to + /// defensively prevent leaking channel-scoped events if the ingest + /// invariant (`is_global_only_kind`) ever changes. + /// Mutually exclusive with `channel_id`. + pub global_only: bool, } /// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers; @@ -130,6 +141,20 @@ pub async fn insert_event( /// Uses `QueryBuilder` for dynamic filter composition — avoids string concatenation /// while keeping all user values in bind parameters. pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result> { + // Composite cursor requires both halves. + if q.before_id.is_some() && q.until.is_none() { + return Err(DbError::InvalidData( + "before_id requires until to be set".to_string(), + )); + } + + // global_only and channel_id are mutually exclusive. + if q.global_only && q.channel_id.is_some() { + return Err(DbError::InvalidData( + "global_only and channel_id are mutually exclusive".to_string(), + )); + } + // kinds:[] means "match no kinds" — return empty immediately. if q.kinds.as_deref().is_some_and(|k| k.is_empty()) { return Ok(vec![]); @@ -162,6 +187,8 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result Result cursor_id) + qb.push(format!(" AND ({col_prefix}created_at < ")); + qb.push_bind(u); + qb.push(format!(" OR ({col_prefix}created_at = ")); + qb.push_bind(u); + qb.push(format!(" AND {col_prefix}id > ")); + qb.push_bind(bid.clone()); + qb.push("))"); + } else { + qb.push(format!(" AND {col_prefix}created_at <= ")) + .push_bind(u); + } } if let Some(ref d) = q.d_tag { @@ -191,8 +231,16 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result Result Result> { + let row = sqlx::query( + "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ + FROM events \ + WHERE kind = $1 AND pubkey = $2 AND channel_id IS NULL AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC \ + LIMIT 1", + ) + .bind(kind) + .bind(pubkey_bytes) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => row_to_stored_event(r), + None => Ok(None), + } +} + /// Fetches a single event by its raw 32-byte ID, **including soft-deleted rows**. /// /// Most callers should use [`get_event_by_id`] instead. This variant is needed diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index d481a1ad..d549deb3 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -216,6 +216,19 @@ impl Db { event::query_events(&self.pool, q).await } + /// Fetch the latest replaceable event for a (kind, pubkey) pair. + /// + /// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`. + /// This matches the write path in [`replace_addressable_event`] and handles + /// historical duplicate survivors correctly. + pub async fn get_latest_global_replaceable( + &self, + kind: i32, + pubkey_bytes: &[u8], + ) -> Result> { + event::get_latest_global_replaceable(&self.pool, kind, pubkey_bytes).await + } + /// Fetches a single non-deleted event by its raw ID bytes. /// /// Returns `None` if the event does not exist or has been soft-deleted. diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index d28a3ba5..c195d499 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -190,6 +190,18 @@ pub async fn get_user_by_nip05( )) } +/// Escape SQL LIKE metacharacters (`%`, `_`, `\`) so user input is treated +/// as literal text. Used with `ESCAPE '\'` in the query. +/// +/// Without this, a search query of `"%"` would match every row (full table +/// scan) and `"_"` would act as a single-character wildcard. +fn escape_like(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + /// Search users by display name, NIP-05 handle, or pubkey prefix. /// /// Empty queries return an empty vec and do not hit the database. @@ -203,25 +215,26 @@ pub async fn search_users( return Ok(Vec::new()); } - let contains_pattern = format!("%{normalized}%"); - let prefix_pattern = format!("{normalized}%"); + let escaped = escape_like(&normalized); + let contains_pattern = format!("%{escaped}%"); + let prefix_pattern = format!("{escaped}%"); let limit = limit.clamp(1, 50) as i64; let rows = sqlx::query_as::<_, (Vec, Option, Option, Option)>( r#" SELECT pubkey, display_name, avatar_url, nip05_handle FROM users - WHERE LOWER(COALESCE(display_name, '')) LIKE $1 - OR LOWER(COALESCE(nip05_handle, '')) LIKE $1 - OR LOWER(encode(pubkey, 'hex')) LIKE $1 + WHERE LOWER(COALESCE(display_name, '')) LIKE $1 ESCAPE '\' + OR LOWER(COALESCE(nip05_handle, '')) LIKE $1 ESCAPE '\' + OR LOWER(encode(pubkey, 'hex')) LIKE $1 ESCAPE '\' ORDER BY CASE WHEN LOWER(COALESCE(display_name, '')) = $2 THEN 0 WHEN LOWER(COALESCE(nip05_handle, '')) = $2 THEN 1 WHEN LOWER(encode(pubkey, 'hex')) = $2 THEN 2 - WHEN LOWER(COALESCE(display_name, '')) LIKE $3 THEN 3 - WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $3 THEN 4 - WHEN LOWER(encode(pubkey, 'hex')) LIKE $3 THEN 5 + WHEN LOWER(COALESCE(display_name, '')) LIKE $3 ESCAPE '\' THEN 3 + WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $3 ESCAPE '\' THEN 4 + WHEN LOWER(encode(pubkey, 'hex')) LIKE $3 ESCAPE '\' THEN 5 ELSE 6 END, COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex'))) @@ -524,6 +537,42 @@ mod tests { assert!(result.is_err(), "should reject invalid policy value"); } + // ── LIKE escaping unit tests (no DB required) ────────────────────── + + // Use the production `escape_like` function directly — no local mirror. + use super::escape_like; + + #[test] + fn like_escape_percent() { + assert_eq!(escape_like("%"), "\\%"); + assert_eq!(escape_like("100%match"), "100\\%match"); + } + + #[test] + fn like_escape_underscore() { + assert_eq!(escape_like("_"), "\\_"); + assert_eq!(escape_like("a_b"), "a\\_b"); + } + + #[test] + fn like_escape_backslash() { + assert_eq!(escape_like("\\"), "\\\\"); + assert_eq!(escape_like("a\\b"), "a\\\\b"); + } + + #[test] + fn like_escape_combined() { + // All three metacharacters in one string + assert_eq!(escape_like("%_\\"), "\\%\\_\\\\"); + } + + #[test] + fn like_escape_normal_input_unchanged() { + assert_eq!(escape_like("alice"), "alice"); + assert_eq!(escape_like("bob@example.com"), "bob@example.com"); + assert_eq!(escape_like(""), ""); + } + /// A user with "owner_only" policy but no agent_owner_pubkey set should /// return Some(("owner_only", None)). #[tokio::test] diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index c3ff2ead..a88e920e 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -35,6 +35,18 @@ fn percent_encode(s: &str) -> String { out } +/// Validate that a string is exactly 64 hex characters. +fn validate_hex64(s: &str, label: &str) -> Result<(), String> { + if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) { + Err(format!( + "Error: {label} must be exactly 64 hex characters, got len={}", + s.len() + )) + } else { + Ok(()) + } +} + /// Validate that `s` is a well-formed UUID (any version/variant). /// Returns `Ok(())` on success, or an error string on failure. fn validate_uuid(s: &str) -> Result<(), String> { @@ -642,6 +654,71 @@ pub struct VoteOnPostParams { pub direction: VoteDirection, } +/// Parameters for [`SproutServer::publish_note`]. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PublishNoteParams { + /// Text content of the note (max 64 KiB). + pub content: String, + /// 64-char hex event ID to reply to. Adds a single e-tag with "reply" marker. + #[serde(default)] + pub reply_to_event_id: Option, +} + +/// A single contact entry for [`SetContactListParams`]. +/// +/// Kept local to MCP — not part of sprout-sdk. The SDK builder takes primitive slices. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ContactEntry { + /// 64-char hex pubkey (any case accepted, normalized to lowercase). + pub pubkey: String, + /// Optional relay URL hint (NIP-02). Empty string if omitted. + #[serde(default)] + pub relay_url: Option, + /// Optional petname / display alias. + #[serde(default)] + pub petname: Option, +} + +/// Parameters for [`SproutServer::set_contact_list`]. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct SetContactListParams { + /// Replaces the **entire** contact list. Call `get_contact_list` first for delta updates. + pub contacts: Vec, +} + +/// Parameters for [`SproutServer::get_event`]. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct GetEventParams { + /// 64-char hex event ID. + pub event_id: String, +} + +/// Parameters for [`SproutServer::get_user_notes`]. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct GetUserNotesParams { + /// 64-char hex pubkey of the author. + pub pubkey: String, + /// Maximum number of notes to return (default 50, max 100). + #[serde(default)] + pub limit: Option, + /// Unix timestamp cursor — return notes created before this time. + /// Use with `before_id` for stable composite cursor pagination. + #[serde(default)] + pub before: Option, + /// Hex event ID cursor for composite keyset pagination. Use together with + /// `before` to avoid skipping same-second events. Pass the `before_id` value + /// from the previous page's `next_cursor` response. + #[serde(default)] + pub before_id: Option, +} + +/// Parameters for [`SproutServer::get_contact_list`]. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct GetContactListParams { + /// 64-char hex pubkey of the user whose contact list to fetch. + pub pubkey: String, +} + // ── Diff utility functions ──────────────────────────────────────────────────── // Truncation notice appended when a diff is cut. This constant is used to @@ -2382,6 +2459,165 @@ with kind:45003 comments)." Err(e) => format!("Error: {e}"), } } + + // ── Social tools ───────────────────────────────────────────────────────── + + /// Publish a kind:1 text note (global, no channel scope). + #[tool( + name = "publish_note", + description = "Publish a short text note (kind:1) to the global feed. Optionally reply to another note by event ID." + )] + pub async fn publish_note(&self, Parameters(p): Parameters) -> String { + let reply_id = match p.reply_to_event_id.as_deref() { + Some(hex) => match EventId::from_hex(hex) { + Ok(id) => Some(id), + Err(e) => return format!("Error: invalid reply_to_event_id: {e}"), + }, + None => None, + }; + + if p.content.len() > 64 * 1024 { + return format!( + "Error: content exceeds 64 KiB limit ({} bytes)", + p.content.len() + ); + } + + let builder = match sprout_sdk::build_note(&p.content, reply_id) { + Ok(b) => b, + Err(e) => return format!("Error: {e}"), + }; + + let event = match builder.sign_with_keys(self.client.keys()) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign event: {e}"), + }; + + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), + Err(e) => format!("Error: {e}"), + } + } + + /// Replace the authenticated user's contact list (kind:3). + #[tool( + name = "set_contact_list", + description = "Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. Call get_contact_list first for delta updates." + )] + pub async fn set_contact_list( + &self, + Parameters(p): Parameters, + ) -> String { + let contacts: Vec<(&str, Option<&str>, Option<&str>)> = p + .contacts + .iter() + .map(|c| { + ( + c.pubkey.as_str(), + c.relay_url.as_deref(), + c.petname.as_deref(), + ) + }) + .collect(); + + let builder = match sprout_sdk::build_contact_list(&contacts) { + Ok(b) => b, + Err(e) => return format!("Error: {e}"), + }; + + let event = match builder.sign_with_keys(self.client.keys()) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign event: {e}"), + }; + + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), + Err(e) => format!("Error: {e}"), + } + } + + /// Fetch a single event by event ID. + #[tool( + name = "get_event", + description = "Fetch a single event by its 64-char hex event ID. For global events: kind:0 profiles and kind:3 contacts require UsersRead scope; kind:1 notes and kind:30023 articles require MessagesRead scope. For channel events: requires MessagesRead scope and channel membership. Unknown kinds return 404." + )] + pub async fn get_event(&self, Parameters(p): Parameters) -> String { + if let Err(e) = validate_hex64(&p.event_id, "event_id") { + return e; + } + let path = format!("/api/events/{}", p.event_id); + match self.client.get(&path).await { + Ok(body) => body, + Err(e) => format!("Error: {e}"), + } + } + + /// List notes by a specific user. + #[tool( + name = "get_user_notes", + description = "List kind:1 text notes by a specific user (by hex pubkey). Returns id, pubkey, created_at, and content per note (tags and sig omitted — use get_event for full events). Supports composite cursor pagination via `before` (Unix timestamp) and `before_id` (hex event ID)." + )] + pub async fn get_user_notes(&self, Parameters(p): Parameters) -> String { + if let Err(e) = validate_hex64(&p.pubkey, "pubkey") { + return e; + } + if let Some(ref bid) = p.before_id { + if let Err(e) = validate_hex64(bid, "before_id") { + return e; + } + } + if p.before_id.is_some() && p.before.is_none() { + return "Error: before_id requires before".to_string(); + } + let mut url = format!("/api/users/{}/notes", p.pubkey); + let mut query_parts = vec![]; + if let Some(limit) = p.limit { + query_parts.push(format!("limit={limit}")); + } + if let Some(before) = p.before { + query_parts.push(format!("before={before}")); + } + if let Some(ref before_id) = p.before_id { + query_parts.push(format!("before_id={before_id}")); + } + if !query_parts.is_empty() { + url.push('?'); + url.push_str(&query_parts.join("&")); + } + match self.client.get(&url).await { + Ok(body) => body, + Err(e) => format!("Error: {e}"), + } + } + + /// Get a user's contact/follow list. + #[tool( + name = "get_contact_list", + description = "Get a user's contact/follow list (kind:3) by hex pubkey. Returns the latest replaceable event." + )] + pub async fn get_contact_list( + &self, + Parameters(p): Parameters, + ) -> String { + if let Err(e) = validate_hex64(&p.pubkey, "pubkey") { + return e; + } + let path = format!("/api/users/{}/contact-list", p.pubkey); + match self.client.get(&path).await { + Ok(body) => body, + Err(e) => format!("Error: {e}"), + } + } } #[tool_handler] diff --git a/crates/sprout-mcp/src/toolsets.rs b/crates/sprout-mcp/src/toolsets.rs index 516bbd3b..ed3b067d 100644 --- a/crates/sprout-mcp/src/toolsets.rs +++ b/crates/sprout-mcp/src/toolsets.rs @@ -26,6 +26,7 @@ //! | `workflow_admin`| 5 | //! | `identity` | 1 | //! | `forums` | 1 | +//! | `social` | 5 | use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; @@ -40,7 +41,7 @@ use std::sync::LazyLock; /// classification. `is_read = true` means the tool is safe to include under /// a `:ro` (read-only) mode restriction. /// -/// 43 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented. +/// See [`DEFERRED_TOOLS`] for tools planned but not yet implemented. pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ // ── default ───────────────────────────────────────────────────────────── ("send_message", "default", false), @@ -92,6 +93,16 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ ("set_channel_add_policy", "identity", false), // ── forums ─────────────────────────────────────────────────────────────── ("vote_on_post", "forums", false), + // ── social ─────────────────────────────────────────────────────────────── + // Social tools for NIP-01/NIP-02 (text notes + contact lists). + // `get_event` returns global events (kind:0/1/3/30023) with scope checks + // and channel events with membership verification. Unknown global kinds + // return 404 (closed-default allowlist in events.rs). + ("publish_note", "social", false), + ("set_contact_list", "social", false), + ("get_event", "social", true), + ("get_user_notes", "social", true), + ("get_contact_list", "social", true), // Deferred tools (not yet implemented): upload_file, subscribe, unsubscribe ]; @@ -157,6 +168,7 @@ const KNOWN_TOOLSETS: &[&str] = &[ "realtime", "identity", "forums", + "social", ]; // --------------------------------------------------------------------------- @@ -319,7 +331,7 @@ mod tests { } #[test] - fn all_includes_all_41_tools() { + fn all_includes_all_tools() { assert_eq!(enabled_tools("all").len(), ALL_TOOLS.len()); } @@ -371,8 +383,8 @@ mod tests { } #[test] - fn all_tools_count_is_43() { - assert_eq!(ALL_TOOLS.len(), 43); + fn all_tools_count_is_48() { + assert_eq!(ALL_TOOLS.len(), 48); } #[test] @@ -396,14 +408,15 @@ mod tests { #[test] fn all_toolsets_returns_correct_count() { - // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums + // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social // (media and realtime have no implemented tools yet) let defs = all_toolsets(); - assert_eq!(defs.len(), 7); + assert_eq!(defs.len(), 8); let names: Vec<_> = defs.iter().map(|d| d.name).collect(); assert!(names.contains(&"default")); assert!(names.contains(&"canvas")); assert!(names.contains(&"forums")); + assert!(names.contains(&"social")); } // ── Cross-check: ALL_TOOLS integrity ──────────────────────────────────── diff --git a/crates/sprout-relay/src/api/events.rs b/crates/sprout-relay/src/api/events.rs index de872e86..4f9ee189 100644 --- a/crates/sprout-relay/src/api/events.rs +++ b/crates/sprout-relay/src/api/events.rs @@ -20,23 +20,33 @@ use super::{ internal_error, not_found, RestAuthMethod, }; +use sprout_core::kind::{ + event_kind_u32, KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_TEXT_NOTE, +}; + +/// Global event kinds that require `UsersRead` scope. +pub(crate) const GLOBAL_USER_DATA_KINDS: [u32; 2] = [KIND_PROFILE, KIND_CONTACT_LIST]; +/// Global event kinds that require `MessagesRead` scope. +pub(crate) const GLOBAL_MESSAGE_KINDS: [u32; 2] = [KIND_TEXT_NOTE, KIND_LONG_FORM]; + /// Fetch a single stored event by its 64-char hex ID. pub async fn get_event( State(state): State>, headers: HeaderMap, Path(event_id): Path, ) -> Result, (StatusCode, Json)> { + // Step 1: authenticate (no scope check yet) let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(super::scope_error)?; let pubkey_bytes = ctx.pubkey_bytes.clone(); + // Step 2: parse event ID let id_bytes = hex::decode(&event_id) .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event ID"))?; if id_bytes.len() != 32 { return Err(api_error(StatusCode::BAD_REQUEST, "invalid event ID")); } + // Step 3: load the event (no scope check yet) let stored_event = state .db .get_event_by_id(&id_bytes) @@ -44,26 +54,46 @@ pub async fn get_event( .map_err(|e| internal_error(&format!("db error: {e}")))? .ok_or_else(|| not_found("event not found"))?; + // Step 4: scope check depends on whether this is a channel event or a global event if let Some(channel_id) = stored_event.channel_id { - // Token-level channel restriction check (in addition to membership check). - // channel_id is obtained from the event's stored metadata — no extra lookup needed. - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; + // Channel event: MessagesRead + membership check. + // All failures return 404 (not 403) to avoid leaking event existence. + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) + .map_err(|_| not_found("event not found"))?; + check_token_channel_access(&ctx, &channel_id).map_err(|_| not_found("event not found"))?; + check_channel_access(&state, channel_id, &pubkey_bytes) + .await + .map_err(|_| not_found("event not found"))?; } else { - return Err(not_found("event not found")); + // Global event — scope-aware allowlist. + let event_kind = event_kind_u32(&stored_event.event); + + let scope_ok = if GLOBAL_USER_DATA_KINDS.contains(&event_kind) { + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).is_ok() + } else if GLOBAL_MESSAGE_KINDS.contains(&event_kind) { + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead).is_ok() + } else { + false + }; + + if !scope_ok { + // Return 404 (not 403) to avoid leaking event existence + // when the caller lacks the required scope. + return Err(not_found("event not found")); + } } let tags = serde_json::to_value(&stored_event.event.tags) .map_err(|e| internal_error(&format!("tag serialization error: {e}")))?; Ok(Json(serde_json::json!({ - "id": stored_event.event.id.to_hex(), - "pubkey": stored_event.event.pubkey.to_hex(), + "id": stored_event.event.id.to_hex(), + "pubkey": stored_event.event.pubkey.to_hex(), "created_at": stored_event.event.created_at.as_u64(), - "kind": stored_event.event.kind.as_u16(), - "tags": tags, - "content": stored_event.event.content, - "sig": stored_event.event.sig.to_string(), + "kind": stored_event.event.kind.as_u16(), + "tags": tags, + "content": stored_event.event.content, + "sig": stored_event.event.sig.to_string(), }))) } @@ -116,3 +146,132 @@ pub async fn submit_event( }, } } + +#[cfg(test)] +mod tests { + use sprout_core::kind::{KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_TEXT_NOTE}; + + use super::{GLOBAL_MESSAGE_KINDS, GLOBAL_USER_DATA_KINDS}; + + /// Reproduce the scope-check routing logic from `get_event` so we can + /// unit-test it without standing up a full HTTP server. + fn scope_check_for_global_event(event_kind: u32, scopes: &[sprout_auth::Scope]) -> bool { + if GLOBAL_USER_DATA_KINDS.contains(&event_kind) { + sprout_auth::require_scope(scopes, sprout_auth::Scope::UsersRead).is_ok() + } else if GLOBAL_MESSAGE_KINDS.contains(&event_kind) { + sprout_auth::require_scope(scopes, sprout_auth::Scope::MessagesRead).is_ok() + } else { + false + } + } + + // ── Positive cases: correct scope grants access ────────────────────── + + #[test] + fn kind0_profile_allowed_with_users_read() { + assert!(scope_check_for_global_event( + KIND_PROFILE, + &[sprout_auth::Scope::UsersRead], + )); + } + + #[test] + fn kind3_contact_list_allowed_with_users_read() { + assert!(scope_check_for_global_event( + KIND_CONTACT_LIST, + &[sprout_auth::Scope::UsersRead], + )); + } + + #[test] + fn kind1_text_note_allowed_with_messages_read() { + assert!(scope_check_for_global_event( + KIND_TEXT_NOTE, + &[sprout_auth::Scope::MessagesRead], + )); + } + + #[test] + fn kind30023_long_form_allowed_with_messages_read() { + assert!(scope_check_for_global_event( + KIND_LONG_FORM, + &[sprout_auth::Scope::MessagesRead], + )); + } + + // ── Negative cases: wrong scope is denied ──────────────────────────── + + #[test] + fn kind0_profile_denied_with_only_messages_read() { + assert!(!scope_check_for_global_event( + KIND_PROFILE, + &[sprout_auth::Scope::MessagesRead], + )); + } + + #[test] + fn kind3_contact_list_denied_with_only_messages_read() { + assert!(!scope_check_for_global_event( + KIND_CONTACT_LIST, + &[sprout_auth::Scope::MessagesRead], + )); + } + + #[test] + fn kind1_text_note_denied_with_only_users_read() { + assert!(!scope_check_for_global_event( + KIND_TEXT_NOTE, + &[sprout_auth::Scope::UsersRead], + )); + } + + #[test] + fn kind30023_long_form_denied_with_only_users_read() { + assert!(!scope_check_for_global_event( + KIND_LONG_FORM, + &[sprout_auth::Scope::UsersRead], + )); + } + + // ── Closed-default: unknown kinds are always denied ────────────────── + + #[test] + fn unknown_kind_denied_even_with_all_scopes() { + let all_scopes = vec![ + sprout_auth::Scope::UsersRead, + sprout_auth::Scope::MessagesRead, + ]; + // kind:1059 (gift wrap), kind:5 (delete), kind:7 (reaction), kind:9 (stream msg) + for kind in [1059, 5, 7, 9, 9002, 40003, 45001] { + assert!( + !scope_check_for_global_event(kind, &all_scopes), + "kind:{kind} must be denied by the closed-default allowlist" + ); + } + } + + // ── Edge case: empty scopes deny everything ────────────────────────── + + #[test] + fn empty_scopes_deny_all_allowed_kinds() { + let no_scopes: &[sprout_auth::Scope] = &[]; + assert!(!scope_check_for_global_event(KIND_PROFILE, no_scopes)); + assert!(!scope_check_for_global_event(KIND_CONTACT_LIST, no_scopes)); + assert!(!scope_check_for_global_event(KIND_TEXT_NOTE, no_scopes)); + assert!(!scope_check_for_global_event(KIND_LONG_FORM, no_scopes)); + } + + // ── Both scopes together grant access to all allowed kinds ─────────── + + #[test] + fn both_scopes_grant_all_allowed_kinds() { + let both = vec![ + sprout_auth::Scope::UsersRead, + sprout_auth::Scope::MessagesRead, + ]; + assert!(scope_check_for_global_event(KIND_PROFILE, &both)); + assert!(scope_check_for_global_event(KIND_CONTACT_LIST, &both)); + assert!(scope_check_for_global_event(KIND_TEXT_NOTE, &both)); + assert!(scope_check_for_global_event(KIND_LONG_FORM, &both)); + } +} diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index b64c810d..8cddece0 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -64,7 +64,8 @@ pub use presence::{presence_handler, set_presence_handler}; pub use reactions::list_reactions_handler; pub use search::search_handler; pub use users::{ - get_profile, get_user_profile, get_users_batch, put_channel_add_policy, search_users, + get_contact_list, get_profile, get_user_notes, get_user_profile, get_users_batch, + put_channel_add_policy, search_users, }; pub use workflows::{ create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_run_approvals, diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index ba579853..1f9e033f 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -21,6 +21,9 @@ use crate::state::AppState; use super::{api_error, extract_auth_context, internal_error, scope_error}; +use sprout_core::kind::{KIND_CONTACT_LIST, KIND_TEXT_NOTE}; +use sprout_db::event::EventQuery; + /// `GET /api/users/me/profile` — get the authenticated user's profile. /// /// Returns: `{ "pubkey": "", "display_name": "...", "avatar_url": "...", "about": "...", "nip05_handle": "..." }` @@ -114,7 +117,7 @@ pub struct BatchProfilesRequest { pub struct SearchUsersQuery { /// Case-insensitive search query. pub q: String, - /// Maximum number of results to return. + /// Maximum number of results to return (default 8, max 50). pub limit: Option, } @@ -214,10 +217,16 @@ pub async fn search_users( if q.is_empty() { return Ok(Json(serde_json::json!({ "users": [] }))); } + if q.len() > 200 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "search query too long (max 200 characters)", + )); + } let results = state .db - .search_users(q, query.limit.unwrap_or(8)) + .search_users(q, query.limit.unwrap_or(8).min(50)) .await .map_err(|e| internal_error(&format!("db error: {e}")))?; @@ -277,3 +286,161 @@ pub async fn put_channel_add_policy( "agent_owner_pubkey": owner_pk.map(|b| nostr_hex::encode(&b)), }))) } + +// ── Social endpoints ───────────────────────────────────────────────────────── + +/// Query params for [`get_user_notes`]. +#[derive(Debug, Deserialize)] +pub struct NotesQuery { + /// Maximum number of notes to return (capped at 100, default 50). + pub limit: Option, + /// Unix timestamp cursor. When used alone (backward compat), events strictly + /// before this timestamp are returned (subtracts 1 second). When used together + /// with `before_id`, enables composite keyset pagination that correctly handles + /// same-second events. + pub before: Option, + /// Hex event ID cursor. When provided with `before`, enables composite keyset + /// pagination: returns events where `created_at < before` OR + /// `(created_at = before AND id > before_id)`. This prevents same-second events + /// from being skipped during pagination. + pub before_id: Option, +} + +/// `GET /api/users/{pubkey}/notes` — list kind:1 text notes by a user. +/// +/// Returns `{id, pubkey, created_at, content}` per note (tags and sig omitted +/// for brevity). Use `GET /api/events/{id}` for the full event including tags +/// and signature. +pub async fn get_user_notes( + State(state): State>, + headers: HeaderMap, + Path(pubkey): Path, + Query(params): Query, +) -> Result, (StatusCode, Json)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) + .map_err(scope_error)?; + + let pubkey_bytes = nostr_hex::decode(&pubkey) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?; + if pubkey_bytes.len() != 32 { + return Err(api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex")); + } + + let limit = params.limit.unwrap_or(50).clamp(1, 100) as i64; + + // Composite cursor: when both `before` and `before_id` are provided, use exact + // keyset pagination (no ±1 adjustment needed — the DB clause is strictly exclusive). + // Simple cursor (backward compat): subtract 1 second so the last event on the + // previous page is not repeated (DB uses `<=` for the simple case). + let (until, before_id_bytes) = match (params.before, params.before_id.as_deref()) { + (Some(ts), Some(id_hex)) => { + // Composite cursor — validate and decode the event ID. + let id_bytes = nostr_hex::decode(id_hex) + .ok() + .filter(|b| b.len() == 32) + .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "invalid before_id hex"))?; + let dt = chrono::DateTime::from_timestamp(ts, 0) + .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "invalid before timestamp"))?; + (Some(dt), Some(id_bytes)) + } + (Some(ts), None) => { + // Simple cursor: subtract 1 second for exclusivity (DB uses <=). + let dt = chrono::DateTime::from_timestamp(ts.saturating_sub(1), 0) + .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "invalid before timestamp"))?; + (Some(dt), None) + } + (None, Some(_)) => { + return Err(api_error( + StatusCode::BAD_REQUEST, + "before_id requires before", + )); + } + (None, None) => (None, None), + }; + + let q = EventQuery { + pubkey: Some(pubkey_bytes), + kinds: Some(vec![KIND_TEXT_NOTE as i32]), + limit: Some(limit), + until, + before_id: before_id_bytes, + // Defensive: only return global events (channel_id IS NULL). + // kind:1 is always stored globally by is_global_only_kind(), but + // this explicit filter prevents information disclosure if that + // invariant ever changes. + global_only: true, + ..Default::default() + }; + + let events = state + .db + .query_events(&q) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + // Composite cursor: include both timestamp and event ID so the next page + // can use exact keyset pagination without same-second skipping. + let next_cursor = if events.len() == limit as usize { + events.last().map(|e| { + serde_json::json!({ + "before": e.event.created_at.as_u64() as i64, + "before_id": e.event.id.to_hex(), + }) + }) + } else { + None + }; + let notes: Vec<_> = events + .into_iter() + .map(|e| { + serde_json::json!({ + "id": e.event.id.to_hex(), + "pubkey": e.event.pubkey.to_hex(), + "created_at": e.event.created_at.as_u64(), + "content": e.event.content, + }) + }) + .collect(); + + Ok(Json( + serde_json::json!({ "notes": notes, "next_cursor": next_cursor }), + )) +} + +/// `GET /api/users/{pubkey}/contact-list` — get a user's kind:3 contact list. +pub async fn get_contact_list( + State(state): State>, + headers: HeaderMap, + Path(pubkey): Path, +) -> Result, (StatusCode, Json)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?; + + let pubkey_bytes = nostr_hex::decode(&pubkey) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?; + if pubkey_bytes.len() != 32 { + return Err(api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex")); + } + + let event = state + .db + .get_latest_global_replaceable(KIND_CONTACT_LIST as i32, &pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + match event { + Some(e) => { + let tags = serde_json::to_value(&e.event.tags) + .map_err(|err| internal_error(&format!("tag serialization error: {err}")))?; + Ok(Json(serde_json::json!({ + "id": e.event.id.to_hex(), + "pubkey": e.event.pubkey.to_hex(), + "created_at": e.event.created_at.as_u64(), + "tags": tags, + "content": e.event.content, + }))) + } + None => Err(api_error(StatusCode::NOT_FOUND, "contact list not found")), + } +} diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 57cc5635..4a5d3d45 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -137,6 +137,11 @@ pub fn build_router(state: Arc) -> Router { ) .route("/api/users/search", get(api::search_users)) .route("/api/users/{pubkey}/profile", get(api::get_user_profile)) + .route("/api/users/{pubkey}/notes", get(api::get_user_notes)) + .route( + "/api/users/{pubkey}/contact-list", + get(api::get_contact_list), + ) .route("/api/users/batch", post(api::get_users_batch)) // Feed route .route("/api/feed", get(api::feed_handler)) diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs index eb700a87..a000f6a9 100644 --- a/crates/sprout-sdk/src/builders.rs +++ b/crates/sprout-sdk/src/builders.rs @@ -1,4 +1,4 @@ -//! The 23 typed event builder functions. +//! The 25 typed event builder functions. //! //! All functions return `Result`. //! The caller signs: `builder.sign_with_keys(&keys)?`. @@ -478,6 +478,91 @@ pub fn build_delete_channel(channel_id: Uuid) -> Result Ok(EventBuilder::new(Kind::Custom(9008), "", tags)) } +// ── Builder 24: build_note ─────────────────────────────────────────────────── + +/// Build a global text note (kind:1, NIP-01). +/// +/// `reply_to_event_id`: adds a single `["e", , "", "reply"]` tag. +/// This is intentionally simpler than the full `ThreadRef` mechanism used +/// for channel messages — social notes use a flat reply model for now. +/// Full NIP-10 threading (root + reply + p-tags) is deferred. +pub fn build_note( + content: &str, + reply_to_event_id: Option, +) -> Result { + check_content(content, 64 * 1024)?; + let mut tags = vec![]; + if let Some(reply_id) = reply_to_event_id { + tags.push(tag(&["e", &reply_id.to_hex(), "", "reply"])?); + } + Ok(EventBuilder::new(Kind::Custom(1), content, tags)) +} + +// ── Builder 25: build_contact_list ─────────────────────────────────────────── + +/// Maximum number of contacts allowed in a single contact list event. +const MAX_CONTACTS: usize = 10_000; + +/// Build a contact list replacement event (kind:3, NIP-02). +/// +/// Each contact is `(pubkey_hex, relay_url, petname)`. +/// `pubkey_hex` must be exactly 64 hex characters (any case accepted, normalized +/// to lowercase before storage). Non-hex or wrong-length pubkeys are rejected +/// with `SdkError::InvalidInput`. +/// `relay_url` and `petname` may be `None` (stored as empty string per NIP-02). +/// +/// Duplicate pubkeys are silently deduplicated — the first occurrence is kept. +/// +/// Replaces the entire contact list — callers must read-before-write for deltas. +pub fn build_contact_list( + contacts: &[(&str, Option<&str>, Option<&str>)], +) -> Result { + if contacts.len() > MAX_CONTACTS { + return Err(SdkError::InvalidInput(format!( + "contact list exceeds maximum of {} contacts (got {})", + MAX_CONTACTS, + contacts.len() + ))); + } + let mut seen = std::collections::HashSet::with_capacity(contacts.len()); + let mut tags = Vec::with_capacity(contacts.len()); + for &(pubkey_hex, relay_url, petname) in contacts { + if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput(format!( + "contact pubkey must be exactly 64 hex chars, got len={}", + pubkey_hex.len() + ))); + } + if let Some(url) = relay_url { + if url.len() > 2048 { + return Err(SdkError::InvalidInput(format!( + "relay_url exceeds 2048 bytes (got {})", + url.len() + ))); + } + } + if let Some(name) = petname { + if name.len() > 256 { + return Err(SdkError::InvalidInput(format!( + "petname exceeds 256 bytes (got {})", + name.len() + ))); + } + } + let lower = pubkey_hex.to_ascii_lowercase(); + if !seen.insert(lower.clone()) { + continue; + } + tags.push(tag(&[ + "p", + &lower, + relay_url.unwrap_or(""), + petname.unwrap_or(""), + ])?); + } + Ok(EventBuilder::new(Kind::Custom(3), "", tags)) +} + // ── Helper: extract_channel_id ─────────────────────────────────────────────── /// Extract the channel UUID from an event's `h` tag. @@ -1132,4 +1217,180 @@ mod tests { .unwrap(); assert_eq!(extract_channel_id(&ev), None); } + + // ── Builder 24: build_note ─────────────────────────────────────────────── + + #[test] + fn build_note_happy_path() { + let builder = build_note("hello world", None).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.kind, Kind::Custom(1)); + assert_eq!(event.content, "hello world"); + assert!(event.tags.is_empty()); + } + + #[test] + fn build_note_with_reply() { + let keys = nostr::Keys::generate(); + // Create a dummy event to get a valid EventId + let dummy = EventBuilder::new(Kind::Custom(1), "dummy", vec![]) + .sign_with_keys(&keys) + .unwrap(); + let builder = build_note("reply text", Some(dummy.id)).unwrap(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.kind, Kind::Custom(1)); + assert_eq!(event.content, "reply text"); + assert_eq!(event.tags.len(), 1); + let tag = event.tags.iter().next().unwrap(); + assert_eq!(tag.as_slice()[0], "e"); + assert_eq!(tag.as_slice()[1], dummy.id.to_hex()); + assert_eq!(tag.as_slice()[3], "reply"); + } + + #[test] + fn build_note_content_too_large() { + let big = "x".repeat(64 * 1024 + 1); + let err = build_note(&big, None).unwrap_err(); + assert!(matches!(err, SdkError::ContentTooLarge { .. })); + } + + #[test] + fn build_note_empty_content() { + // Empty content is valid per NIP-01. + let builder = build_note("", None).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.kind, Kind::Custom(1)); + assert_eq!(event.content, ""); + assert!(event.tags.is_empty()); + } + + // ── Builder 25: build_contact_list ─────────────────────────────────────── + + #[test] + fn build_contact_list_happy_path() { + let pubkey = "a".repeat(64); + let contacts = vec![(pubkey.as_str(), None, None)]; + let builder = build_contact_list(&contacts).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.kind, Kind::Custom(3)); + assert_eq!(event.content, ""); + assert_eq!(event.tags.len(), 1); + let tag = event.tags.iter().next().unwrap(); + assert_eq!(tag.as_slice()[0], "p"); + assert_eq!(tag.as_slice()[1], pubkey); + } + + #[test] + fn build_contact_list_normalizes_uppercase() { + let upper = "A".repeat(64); + let contacts = vec![(upper.as_str(), None, None)]; + let builder = build_contact_list(&contacts).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + let tag = event.tags.iter().next().unwrap(); + assert_eq!(tag.as_slice()[1], "a".repeat(64)); + } + + #[test] + fn build_contact_list_with_relay_and_petname() { + let pubkey = "b".repeat(64); + let contacts = vec![( + pubkey.as_str(), + Some("wss://relay.example.com"), + Some("alice"), + )]; + let builder = build_contact_list(&contacts).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + let tag = event.tags.iter().next().unwrap(); + assert_eq!(tag.as_slice()[0], "p"); + assert_eq!(tag.as_slice()[2], "wss://relay.example.com"); + assert_eq!(tag.as_slice()[3], "alice"); + } + + #[test] + fn build_contact_list_empty() { + let builder = build_contact_list(&[]).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.kind, Kind::Custom(3)); + assert!(event.tags.is_empty()); + } + + #[test] + fn build_contact_list_rejects_short_pubkey() { + let short = "a".repeat(63); + let contacts = vec![(short.as_str(), None, None)]; + let err = build_contact_list(&contacts).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_contact_list_rejects_long_pubkey() { + let long = "a".repeat(65); + let contacts = vec![(long.as_str(), None, None)]; + let err = build_contact_list(&contacts).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_contact_list_rejects_non_hex() { + let non_hex = "g".repeat(64); + let contacts = vec![(non_hex.as_str(), None, None)]; + let err = build_contact_list(&contacts).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_contact_list_rejects_long_relay_url() { + let pubkey = "a".repeat(64); + let long_url = "x".repeat(2049); + let contacts = vec![(pubkey.as_str(), Some(long_url.as_str()), None)]; + let err = build_contact_list(&contacts).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_contact_list_rejects_long_petname() { + let pubkey = "a".repeat(64); + let long_name = "x".repeat(257); + let contacts = vec![(pubkey.as_str(), None, Some(long_name.as_str()))]; + let err = build_contact_list(&contacts).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_contact_list_duplicate_pubkeys() { + let pubkey = "c".repeat(64); + // Same pubkey twice — only one p-tag should be emitted. + let contacts = vec![ + (pubkey.as_str(), None, None), + ( + pubkey.as_str(), + Some("wss://relay.example.com"), + Some("bob"), + ), + ]; + let builder = build_contact_list(&contacts).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.tags.len(), 1); + let tag = event.tags.iter().next().unwrap(); + assert_eq!(tag.as_slice()[0], "p"); + assert_eq!(tag.as_slice()[1], pubkey); + } + + #[test] + fn build_contact_list_too_many() { + let pubkey = "d".repeat(64); + // MAX_CONTACTS + 1 entries (all same pubkey — uniqueness doesn't matter, + // the cap is checked before deduplication). + let entry = (pubkey.as_str(), None, None); + let contacts = vec![entry; MAX_CONTACTS + 1]; + let err = build_contact_list(&contacts).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } } diff --git a/crates/sprout-sdk/src/lib.rs b/crates/sprout-sdk/src/lib.rs index e4647cd0..1b94b2be 100644 --- a/crates/sprout-sdk/src/lib.rs +++ b/crates/sprout-sdk/src/lib.rs @@ -99,4 +99,7 @@ pub enum SdkError { /// Diff metadata failed validation. #[error("invalid diff metadata: {0}")] InvalidDiffMeta(String), + /// Input failed validation (e.g. malformed pubkey). + #[error("invalid input: {0}")] + InvalidInput(String), } diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index 29acf2f7..ef794fcf 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -1520,3 +1520,340 @@ async fn test_set_channel_add_policy_rejects_invalid() { } // ── Thread reply mention p-tag tests ────────────────────────────────────────── + +// ── Notes (kind:1) tests ────────────────────────────────────────────────────── + +/// Phase 1: GET /api/events/{id} must return 200 for kind:1 text note. +#[tokio::test] +#[ignore] +async fn test_get_event_returns_text_note() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let content = format!("e2e-note-{}", uuid::Uuid::new_v4().simple()); + let event = EventBuilder::new(Kind::Custom(1), &content, vec![]) + .sign_with_keys(&keys) + .unwrap(); + let event_id = event.id.to_hex(); + + let resp = client + .post(format!("{}/api/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit kind:1 event"); + assert!( + resp.status().is_success(), + "kind:1 submission failed: {}", + resp.status() + ); + + let url = format!("{}/api/events/{}", relay_http_url(), event_id); + let resp = authed_get(&client, &url, &pubkey_hex).await; + assert_eq!(resp.status(), 200, "expected 200 for known kind:1 event"); + + let body: serde_json::Value = resp.json().await.expect("json"); + assert_eq!( + body["id"].as_str(), + Some(event_id.as_str()), + "id must match" + ); + assert_eq!(body["kind"].as_u64(), Some(1), "kind must be 1"); + assert_eq!( + body["content"].as_str(), + Some(content.as_str()), + "content must match" + ); +} + +/// Phase 1: GET /api/events/{id} must return 404 for unknown / disallowed event IDs. +#[tokio::test] +#[ignore] +async fn test_get_event_rejects_unknown_global_kind() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Use a well-formed but non-existent event ID (all-zeros hex). + let fake_id = "0".repeat(64); + let url = format!("{}/api/events/{}", relay_http_url(), fake_id); + let resp = authed_get(&client, &url, &pubkey_hex).await; + assert_eq!(resp.status(), 404, "unknown event ID should return 404"); +} + +/// Phase 2: GET /api/users/{pubkey}/notes returns paginated notes. +#[tokio::test] +#[ignore] +async fn test_get_user_notes_returns_paginated_notes() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Publish 3 kind:1 events for this key. No sleep needed — the composite cursor + // (created_at, event_id) correctly handles same-second events without skipping. + for i in 0..3u8 { + let content = format!("e2e-paginated-note-{}-{}", i, uuid::Uuid::new_v4().simple()); + let event = EventBuilder::new(Kind::Custom(1), &content, vec![]) + .sign_with_keys(&keys) + .unwrap(); + let resp = client + .post(format!("{}/api/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit kind:1 event"); + assert!( + resp.status().is_success(), + "kind:1 submission {} failed: {}", + i, + resp.status() + ); + } + + // First page: limit=2 — expect exactly 2 results and a next_cursor. + let page1_url = format!( + "{}/api/users/{}/notes?limit=2", + relay_http_url(), + pubkey_hex + ); + let resp = authed_get(&client, &page1_url, &pubkey_hex).await; + assert_eq!(resp.status(), 200, "expected 200 for first page of notes"); + + let body: serde_json::Value = resp.json().await.expect("json"); + let notes = body["notes"] + .as_array() + .expect("body must have a 'notes' array"); + assert_eq!(notes.len(), 2, "first page should return exactly 2 notes"); + + // next_cursor is now a JSON object with `before` (i64 timestamp) and + // `before_id` (hex event ID) for stable composite cursor pagination. + let cursor = &body["next_cursor"]; + assert!( + cursor.is_object(), + "next_cursor must be a JSON object when more results exist, got: {cursor}" + ); + let cursor_before = cursor["before"] + .as_i64() + .expect("next_cursor.before must be a JSON number"); + let cursor_before_id = cursor["before_id"] + .as_str() + .expect("next_cursor.before_id must be a string"); + + // Collect page 1 event IDs for duplicate checking. + let page1_ids: std::collections::HashSet = notes + .iter() + .filter_map(|n| n["id"].as_str().map(|s| s.to_string())) + .collect(); + + // Second page: use composite cursor — expect at least 1 remaining note. + let page2_url = format!( + "{}/api/users/{}/notes?limit=2&before={}&before_id={}", + relay_http_url(), + pubkey_hex, + cursor_before, + cursor_before_id + ); + let resp = authed_get(&client, &page2_url, &pubkey_hex).await; + assert_eq!(resp.status(), 200, "expected 200 for second page of notes"); + + let body: serde_json::Value = resp.json().await.expect("json"); + let notes = body["notes"] + .as_array() + .expect("body must have a 'notes' array"); + assert!( + !notes.is_empty(), + "second page should return at least 1 remaining note" + ); + + // No event from page 1 should appear on page 2. + for note in notes { + let id = note["id"].as_str().unwrap_or(""); + assert!( + !page1_ids.contains(id), + "event {id} appeared on both page 1 and page 2 (duplicate)" + ); + } +} + +/// Phase 2: GET /api/users/{pubkey}/contact-list returns latest kind:3. +/// Also verifies replacement semantics: a newer kind:3 must supersede the older one. +#[tokio::test] +#[ignore] +async fn test_get_contact_list_returns_latest() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // ── First contact list: 2 contacts ─────────────────────────────────────── + let contact1 = Keys::generate().public_key().to_hex(); + let contact2 = Keys::generate().public_key().to_hex(); + let tags_v1 = vec![ + Tag::parse(&["p", &contact1]).unwrap(), + Tag::parse(&["p", &contact2]).unwrap(), + ]; + let event_v1 = EventBuilder::new(Kind::Custom(3), "", tags_v1) + .sign_with_keys(&keys) + .unwrap(); + + let resp = client + .post(format!("{}/api/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event_v1).unwrap()) + .send() + .await + .expect("submit first kind:3 event"); + assert!( + resp.status().is_success(), + "first kind:3 submission failed: {}", + resp.status() + ); + + // Wait 1 second so the replacement event gets a strictly greater created_at. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // ── Second contact list: 1 different contact ────────────────────────────── + let contact3 = Keys::generate().public_key().to_hex(); + let tags_v2 = vec![Tag::parse(&["p", &contact3]).unwrap()]; + let event_v2 = EventBuilder::new(Kind::Custom(3), "", tags_v2) + .sign_with_keys(&keys) + .unwrap(); + + let resp = client + .post(format!("{}/api/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event_v2).unwrap()) + .send() + .await + .expect("submit second kind:3 event"); + assert!( + resp.status().is_success(), + "second kind:3 submission failed: {}", + resp.status() + ); + + // ── Fetch and assert replacement ────────────────────────────────────────── + let url = format!("{}/api/users/{}/contact-list", relay_http_url(), pubkey_hex); + let resp = authed_get(&client, &url, &pubkey_hex).await; + assert_eq!( + resp.status(), + 200, + "expected 200 for contact-list after publishing kind:3" + ); + + let body: serde_json::Value = resp.json().await.expect("json"); + let tags = body["tags"] + .as_array() + .expect("body must have 'tags' array"); + let p_tags: Vec<_> = tags + .iter() + .filter(|t| { + t.as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + == Some("p") + }) + .collect(); + // The second event (1 contact) must have replaced the first (2 contacts). + assert_eq!( + p_tags.len(), + 1, + "expected 1 'p' tag — the second (replacement) contact list should supersede the first" + ); + // The single remaining contact must be contact3, not contact1 or contact2. + let remaining_pubkey = p_tags[0] + .as_array() + .and_then(|a| a.get(1)) + .and_then(|v| v.as_str()) + .expect("p-tag must have a pubkey value"); + assert_eq!( + remaining_pubkey, contact3, + "the surviving contact must be contact3 from the replacement event" + ); +} + +/// Phase 2: GET /api/users/{pubkey}/contact-list returns 404 for unknown pubkey. +#[tokio::test] +#[ignore] +async fn test_get_contact_list_returns_404_for_unknown() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Use a freshly generated key that has never published anything. + let unknown_pubkey = Keys::generate().public_key().to_hex(); + let url = format!( + "{}/api/users/{}/contact-list", + relay_http_url(), + unknown_pubkey + ); + let resp = authed_get(&client, &url, &pubkey_hex).await; + assert_eq!( + resp.status(), + 404, + "unknown pubkey contact-list should return 404" + ); +} + +/// Overflow guard: any out-of-range `before` timestamp must return 400, whether +/// used alone (simple cursor) or with `before_id` (composite cursor). +#[tokio::test] +#[ignore] +async fn test_get_user_notes_invalid_before_returns_400() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Publish one note so the user has something to return. + let content = format!("e2e-overflow-note-{}", uuid::Uuid::new_v4().simple()); + let event = EventBuilder::new(Kind::Custom(1), &content, vec![]) + .sign_with_keys(&keys) + .unwrap(); + let resp = client + .post(format!("{}/api/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit kind:1 event"); + assert!( + resp.status().is_success(), + "kind:1 submission failed: {}", + resp.status() + ); + + // Simple cursor (no before_id): overflow timestamp must return 400. + let url = format!( + "{}/api/users/{}/notes?before=99999999999999", + relay_http_url(), + pubkey_hex + ); + let resp = authed_get(&client, &url, &pubkey_hex).await; + assert_eq!( + resp.status(), + 400, + "simple overflow `before` must return 400" + ); + + // Composite cursor with overflow timestamp must also return 400. + let fake_id = "a".repeat(64); + let url = format!( + "{}/api/users/{}/notes?before=99999999999999&before_id={}", + relay_http_url(), + pubkey_hex, + fake_id + ); + let resp = authed_get(&client, &url, &pubkey_hex).await; + assert_eq!( + resp.status(), + 400, + "composite cursor with overflow `before` must return 400" + ); +}