Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/sprout-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ license.workspace = true
repository.workspace = true
description = "Agent-first CLI for Sprout relay"

[lib]
name = "sprout_cli"
path = "src/lib.rs"

[[bin]]
name = "sprout"
path = "src/main.rs"
Expand Down
76 changes: 62 additions & 14 deletions crates/sprout-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,19 @@ pub struct SproutClient {
http: reqwest::Client,
relay_url: String, // base URL, no trailing slash, e.g. "https://relay.sprout.place"
keys: Keys,
/// Optional NIP-OA auth tag injected into every signed event.
auth_tag: Option<Tag>,
/// Raw JSON of the auth tag for the `x-auth-tag` HTTP header.
auth_tag_json: Option<String>,
}

impl SproutClient {
pub fn new(relay_url: String, keys: Keys) -> Result<Self, CliError> {
pub fn new(
relay_url: String,
keys: Keys,
auth_tag: Option<Tag>,
auth_tag_json: Option<String>,
) -> Result<Self, CliError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
Expand All @@ -136,6 +145,8 @@ impl SproutClient {
http,
relay_url,
keys,
auth_tag,
auth_tag_json,
})
}

Expand All @@ -150,6 +161,46 @@ impl SproutClient {
&self.relay_url
}

/// Sign an event builder, injecting the NIP-OA auth tag if configured.
///
/// All event creation should go through this method to ensure consistent
/// auth tag injection. Callers MUST NOT add `auth` tags to the builder
/// before calling this method.
pub fn sign_event(&self, builder: EventBuilder) -> Result<nostr::Event, CliError> {
let builder = if let Some(ref tag) = self.auth_tag {
builder.add_tags([tag.clone()])
} else {
builder
};
let event = builder
.sign_with_keys(&self.keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;

// Enforce: auth tags may only come from self.auth_tag injection.
let auth_count = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.count();
let expected = if self.auth_tag.is_some() { 1 } else { 0 };
if auth_count != expected {
return Err(CliError::Other(format!(
"event has {auth_count} auth tags — expected {expected}; \
callers must not add auth tags manually"
)));
}

Ok(event)
}

/// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation).
fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match self.auth_tag_json {
Some(ref json) => req.header("x-auth-tag", json),
None => req,
}
}

// -----------------------------------------------------------------------
// HTTP Bridge: POST /query
// -----------------------------------------------------------------------
Expand All @@ -163,14 +214,13 @@ impl SproutClient {
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;

let resp = self
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await?;
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;

self.handle_response(resp).await
}
Expand All @@ -184,14 +234,13 @@ impl SproutClient {
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;

let resp = self
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await?;
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;

self.handle_response(resp).await
}
Expand All @@ -207,14 +256,13 @@ impl SproutClient {
.map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;

let resp = self
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await?;
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;

self.handle_response(resp).await
}
Expand Down Expand Up @@ -316,7 +364,7 @@ impl SproutClient {
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256);

let resp = req.body(bytes).send().await?;
let resp = self.with_auth_tag(req).body(bytes).send().await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Expand Down
42 changes: 14 additions & 28 deletions crates/sprout-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,9 @@ fn parse_uuid(s: &str) -> Result<Uuid, CliError> {

fn sign_and_submit_builder(
builder: nostr::EventBuilder,
keys: &nostr::Keys,
client: &SproutClient,
) -> Result<nostr::Event, CliError> {
builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))
client.sign_event(builder)
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -113,7 +111,6 @@ pub async fn cmd_create_channel(
}
}

let keys = client.keys();
let channel_uuid = Uuid::new_v4();

let vis = match visibility {
Expand All @@ -130,7 +127,7 @@ pub async fn cmd_create_channel(
sprout_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
Expand All @@ -148,13 +145,12 @@ pub async fn cmd_update_channel(
));
}
validate_uuid(channel_id)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_update_channel(channel_uuid, name, description)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
Expand All @@ -166,13 +162,12 @@ pub async fn cmd_set_channel_topic(
topic: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_set_topic(channel_uuid, topic)
.map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
Expand All @@ -184,55 +179,51 @@ pub async fn cmd_set_channel_purpose(
purpose: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_set_purpose(channel_uuid, purpose)
.map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_join(channel_uuid)
.map_err(|e| CliError::Other(format!("build_join failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_leave(channel_uuid)
.map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_archive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
Expand All @@ -243,27 +234,25 @@ pub async fn cmd_unarchive_channel(
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_unarchive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_delete_channel(channel_uuid)
.map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
Expand All @@ -277,7 +266,6 @@ pub async fn cmd_add_channel_member(
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let typed_role = match role {
Expand All @@ -296,7 +284,7 @@ pub async fn cmd_add_channel_member(
let builder = sprout_sdk::build_add_member(channel_uuid, pubkey, typed_role)
.map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
Expand All @@ -309,13 +297,12 @@ pub async fn cmd_remove_channel_member(
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_remove_member(channel_uuid, pubkey)
.map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
Expand All @@ -328,13 +315,12 @@ pub async fn cmd_set_canvas(
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let content = read_or_stdin(content)?;
let keys = client.keys();
let channel_uuid = parse_uuid(channel_id)?;

let builder = sprout_sdk::build_set_canvas(channel_uuid, &content)
.map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?;

let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
Expand Down
10 changes: 2 additions & 8 deletions crates/sprout-cli/src/commands/dms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,13 @@ pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<()
validate_hex64(pk)?;
}

let keys = client.keys();
let mut tags: Vec<Tag> = Vec::new();
for pk in pubkeys {
tags.push(Tag::parse(&["p", pk]).map_err(|e| CliError::Other(format!("tag error: {e}")))?);
}

let builder = EventBuilder::new(Kind::Custom(41010), "", tags);
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;

let resp = client.submit_event(event).await?;
println!("{resp}");
Expand All @@ -52,16 +49,13 @@ pub async fn cmd_add_dm_member(
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;

let keys = client.keys();
let tags = vec![
Tag::parse(&["h", channel_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
Tag::parse(&["p", pubkey]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
];

let builder = EventBuilder::new(Kind::Custom(41011), "", tags);
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;

let resp = client.submit_event(event).await?;
println!("{resp}");
Expand Down
Loading
Loading