From 95234d1213c038c37f034bba575d6b3a8f78f5b5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 17:28:34 -0400 Subject: [PATCH 1/2] feat(sdk): add builder functions for workflows, DMs, and presence Three CLI modules constructed Nostr events by hand using raw EventBuilder with magic kind numbers while every other module used sprout_sdk builders. Added 8 builders (build_workflow_def, build_workflow_update, build_workflow_delete, build_workflow_trigger, build_workflow_approval, build_dm_open, build_dm_add_member, build_presence_update) and updated the CLI consumers to use them. --- crates/sprout-cli/src/commands/dms.rs | 28 +--- crates/sprout-cli/src/commands/users.rs | 20 +-- crates/sprout-cli/src/commands/workflows.rs | 45 ++---- crates/sprout-cli/src/validate.rs | 5 + crates/sprout-sdk/src/builders.rs | 149 +++++++++++++++++++- 5 files changed, 174 insertions(+), 73 deletions(-) diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs index b92e24ee3..dff37b892 100644 --- a/crates/sprout-cli/src/commands/dms.rs +++ b/crates/sprout-cli/src/commands/dms.rs @@ -1,8 +1,6 @@ -use nostr::{EventBuilder, Kind, Tag}; - use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::{validate_hex64, validate_uuid}; +use crate::validate::parse_uuid; /// List DM conversations by querying kind:41010 (DM open) events authored by us. pub async fn cmd_list_dms(client: &SproutClient, limit: Option) -> Result<(), CliError> { @@ -23,16 +21,9 @@ pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<() if pubkeys.is_empty() || pubkeys.len() > 8 { return Err(CliError::Usage("--pubkey: must provide 1–8 pubkeys".into())); } - for pk in pubkeys { - validate_hex64(pk)?; - } - - let mut tags: Vec = 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 refs: Vec<&str> = pubkeys.iter().map(|s| s.as_str()).collect(); + let builder = sprout_sdk::build_dm_open(&refs) + .map_err(|e| CliError::Other(format!("build_dm_open failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -46,15 +37,10 @@ pub async fn cmd_add_dm_member( channel_id: &str, pubkey: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; - validate_hex64(pubkey)?; - - 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 channel_uuid = parse_uuid(channel_id)?; - let builder = EventBuilder::new(Kind::Custom(41011), "", tags); + let builder = sprout_sdk::build_dm_add_member(channel_uuid, pubkey) + .map_err(|e| CliError::Other(format!("build_dm_add_member failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/sprout-cli/src/commands/users.rs b/crates/sprout-cli/src/commands/users.rs index 4e8aee9aa..280e9987f 100644 --- a/crates/sprout-cli/src/commands/users.rs +++ b/crates/sprout-cli/src/commands/users.rs @@ -1,5 +1,3 @@ -use nostr::{EventBuilder, Kind, Tag}; - use crate::client::SproutClient; use crate::error::CliError; use crate::validate::validate_hex64; @@ -220,22 +218,8 @@ pub async fn cmd_get_presence(client: &SproutClient, pubkeys_csv: &str) -> Resul /// This will fail until the CLI gains a WS publish path. The kind is correct /// per the protocol spec (KIND_PRESENCE_UPDATE = 20001). pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), CliError> { - match status { - "online" | "away" | "offline" => {} - _ => { - return Err(CliError::Usage(format!( - "--status must be one of: online, away, offline (got: {status})" - ))) - } - } - - let tags = - vec![Tag::parse(&["status", status]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; - - // KIND_PRESENCE_UPDATE (20001) — ephemeral, WS-only. HTTP bridge will reject this - // until the CLI gains a WebSocket publish path. - let builder = EventBuilder::new(Kind::Custom(20001), "", tags); + let builder = sprout_sdk::build_presence_update(status) + .map_err(|e| CliError::Other(format!("build_presence_update failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/sprout-cli/src/commands/workflows.rs b/crates/sprout-cli/src/commands/workflows.rs index 5d0e3f8e1..8704092f3 100644 --- a/crates/sprout-cli/src/commands/workflows.rs +++ b/crates/sprout-cli/src/commands/workflows.rs @@ -1,9 +1,8 @@ -use nostr::{EventBuilder, Kind, Tag}; use sha2::{Digest, Sha256}; use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::{read_or_stdin, validate_uuid}; +use crate::validate::{parse_uuid, read_or_stdin, validate_uuid}; // --------------------------------------------------------------------------- // Read commands — POST /query @@ -61,17 +60,13 @@ pub async fn cmd_create_workflow( channel_id: &str, yaml: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; + let channel_uuid = parse_uuid(channel_id)?; let yaml_definition = read_or_stdin(yaml)?; // Generate a unique d-tag for this workflow let workflow_id = uuid::Uuid::new_v4().to_string(); - let tags = vec![ - Tag::parse(&["d", &workflow_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, - Tag::parse(&["h", channel_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, - ]; - - let builder = EventBuilder::new(Kind::Custom(30620), &yaml_definition, tags); + let builder = sprout_sdk::build_workflow_def(channel_uuid, &workflow_id, &yaml_definition) + .map_err(|e| CliError::Other(format!("build_workflow_def failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -88,11 +83,8 @@ pub async fn cmd_update_workflow( validate_uuid(workflow_id)?; let yaml_definition = read_or_stdin(yaml)?; - let tags = - vec![Tag::parse(&["d", workflow_id]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; - - let builder = EventBuilder::new(Kind::Custom(30620), &yaml_definition, tags); + let builder = sprout_sdk::build_workflow_update(workflow_id, &yaml_definition) + .map_err(|e| CliError::Other(format!("build_workflow_update failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -105,14 +97,8 @@ pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Re validate_uuid(workflow_id)?; let keys = client.keys(); - // NIP-09 deletion targeting the parameterized replaceable event - let tags = vec![Tag::parse(&[ - "a", - &format!("30620:{}:{}", keys.public_key().to_hex(), workflow_id), - ]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; - - let builder = EventBuilder::new(Kind::Custom(5), "", tags); + let builder = sprout_sdk::build_workflow_delete(&keys.public_key().to_hex(), workflow_id) + .map_err(|e| CliError::Other(format!("build_workflow_delete failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -127,11 +113,8 @@ pub async fn cmd_trigger_workflow( ) -> Result<(), CliError> { validate_uuid(workflow_id)?; - let tags = - vec![Tag::parse(&["d", workflow_id]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; - - let builder = EventBuilder::new(Kind::Custom(46020), "", tags); + let builder = sprout_sdk::build_workflow_trigger(workflow_id) + .map_err(|e| CliError::Other(format!("build_workflow_trigger failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -148,16 +131,12 @@ pub async fn cmd_approve_step( ) -> Result<(), CliError> { validate_uuid(approval_token)?; - let kind = if approved { 46030 } else { 46031 }; let content = note.unwrap_or(""); // The relay expects d-tag = hex(SHA256(token)), not the raw token UUID. let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes())); - let tags = - vec![Tag::parse(&["d", &token_hash]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; - - let builder = EventBuilder::new(Kind::Custom(kind), content, tags); + let builder = sprout_sdk::build_workflow_approval(&token_hash, approved, content) + .map_err(|e| CliError::Other(format!("build_workflow_approval failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs index 88ae7517f..b18d4ec37 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -12,6 +12,11 @@ pub fn validate_uuid(s: &str) -> Result<(), CliError> { Ok(()) } +/// Parse and validate a UUID string, returning the parsed Uuid value. +pub fn parse_uuid(s: &str) -> Result { + uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}"))) +} + /// Validate 64-character lowercase hex string (event_id, pubkey). pub fn validate_hex64(s: &str) -> Result<(), CliError> { if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) { diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs index 2f5bac7c0..2e05899c1 100644 --- a/crates/sprout-sdk/src/builders.rs +++ b/crates/sprout-sdk/src/builders.rs @@ -5,7 +5,11 @@ use nostr::{EventBuilder, Kind, Tag}; use sprout_core::{ - kind::{KIND_AGENT_OBSERVER_FRAME, KIND_GIT_REPO_ANNOUNCEMENT}, + kind::{ + KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION, + KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_GIT_REPO_ANNOUNCEMENT, KIND_PRESENCE_UPDATE, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY, @@ -896,6 +900,149 @@ pub fn build_repo_announcement( )) } +// --------------------------------------------------------------------------- +// Workflow builders +// --------------------------------------------------------------------------- + +/// Build a workflow definition event (kind 30620). +/// +/// - `channel_id`: the channel this workflow belongs to (h-tag) +/// - `workflow_id`: unique d-tag identifier for this workflow +/// - `yaml`: workflow YAML definition as content +pub fn build_workflow_def( + channel_id: Uuid, + workflow_id: &str, + yaml: &str, +) -> Result { + let tags = vec![ + tag(&["d", workflow_id])?, + tag(&["h", &channel_id.to_string()])?, + ]; + Ok(EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + yaml, + tags, + )) +} + +/// Build a workflow update event (kind 30620) for an existing workflow. +/// +/// Updates an existing workflow definition in-place via the parameterized +/// replaceable event mechanism — same d-tag overwrites the previous version. +pub fn build_workflow_update(workflow_id: &str, yaml: &str) -> Result { + let tags = vec![tag(&["d", workflow_id])?]; + Ok(EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + yaml, + tags, + )) +} + +/// Build a NIP-09 deletion event targeting a workflow definition (kind 5). +/// +/// The `a`-tag addresses the parameterized replaceable event `30620::`. +pub fn build_workflow_delete( + author_pubkey: &str, + workflow_id: &str, +) -> Result { + let pk = check_pubkey_hex(author_pubkey, "author_pubkey")?; + let tags = vec![tag(&["a", &format!("30620:{pk}:{workflow_id}")])?]; + Ok(EventBuilder::new( + Kind::Custom(KIND_DELETION as u16), + "", + tags, + )) +} + +/// Build a workflow trigger event (kind 46020). +pub fn build_workflow_trigger(workflow_id: &str) -> Result { + let tags = vec![tag(&["d", workflow_id])?]; + Ok(EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), + "", + tags, + )) +} + +/// Build a workflow approval event — kind 46030 (grant) or 46031 (deny). +/// +/// - `token_hash`: hex-encoded SHA-256 of the approval token UUID (d-tag) +/// - `approved`: `true` emits kind 46030 (grant), `false` emits kind 46031 (deny) +/// - `note`: optional human-readable note as event content +pub fn build_workflow_approval( + token_hash: &str, + approved: bool, + note: &str, +) -> Result { + let kind = if approved { + KIND_APPROVAL_GRANT + } else { + KIND_APPROVAL_DENY + }; + let tags = vec![tag(&["d", token_hash])?]; + Ok(EventBuilder::new(Kind::Custom(kind as u16), note, tags)) +} + +// --------------------------------------------------------------------------- +// DM builders +// --------------------------------------------------------------------------- + +/// Build a DM open event (kind 41010). +/// +/// `pubkeys` must be 1-8 hex-encoded pubkeys to include in the DM conversation. +pub fn build_dm_open(pubkeys: &[&str]) -> Result { + if pubkeys.is_empty() || pubkeys.len() > 8 { + return Err(SdkError::InvalidInput( + "dm open requires 1-8 pubkeys".into(), + )); + } + let mut tags = Vec::with_capacity(pubkeys.len()); + for pk in pubkeys { + let validated = check_pubkey_hex(pk, "pubkey")?; + tags.push(tag(&["p", &validated])?); + } + Ok(EventBuilder::new( + Kind::Custom(KIND_DM_OPEN as u16), + "", + tags, + )) +} + +/// Build a DM add-member event (kind 41011). +pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result { + let pk = check_pubkey_hex(pubkey, "pubkey")?; + let tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["p", &pk])?]; + Ok(EventBuilder::new( + Kind::Custom(KIND_DM_ADD_MEMBER as u16), + "", + tags, + )) +} + +// --------------------------------------------------------------------------- +// Presence builder +// --------------------------------------------------------------------------- + +/// Build a presence update event (kind 20001). +/// +/// `status` must be one of: `"online"`, `"away"`, `"offline"`. +pub fn build_presence_update(status: &str) -> Result { + match status { + "online" | "away" | "offline" => {} + _ => { + return Err(SdkError::InvalidInput(format!( + "status must be online, away, or offline (got: {status})" + ))) + } + } + let tags = vec![tag(&["status", status])?]; + Ok(EventBuilder::new( + Kind::Custom(KIND_PRESENCE_UPDATE as u16), + "", + tags, + )) +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] From 5c92fa9490dbe966bcfa8e6ed22f981bcb53f106 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 17:53:07 -0400 Subject: [PATCH 2/2] fix(sdk): address 5-model crossfire review findings on builder functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence wire format: relay reads status from event.content, not tags. build_presence_update now sets content=status (Codex caught this). Workflow update: relay requires h-tag for channel-scoped authorization. build_workflow_update now takes channel_id parameter (confirmed by Gemini against relay handler source — extract_h_tag_channel rejects events without it). Exit code regression: SdkError::InvalidInput mapped to CliError::Other (exit 4) instead of CliError::Usage (exit 1). Added sdk_err() helper that routes InvalidInput to Usage across all 8 CLI call sites. Additional fixes from review: token_hash hex validation in build_workflow_approval, check_content guard on workflow YAML, hardcoded "30620" replaced with KIND_WORKFLOW_DEF constant, workflow_id typed as Uuid, section headers normalized, 27 new tests. --- crates/sprout-cli/src/commands/dms.rs | 10 +- crates/sprout-cli/src/commands/users.rs | 3 +- crates/sprout-cli/src/commands/workflows.rs | 32 +-- crates/sprout-cli/src/lib.rs | 10 +- crates/sprout-cli/src/validate.rs | 10 + crates/sprout-sdk/src/builders.rs | 248 ++++++++++++++++++-- 6 files changed, 263 insertions(+), 50 deletions(-) diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs index dff37b892..d9ea8913d 100644 --- a/crates/sprout-cli/src/commands/dms.rs +++ b/crates/sprout-cli/src/commands/dms.rs @@ -1,6 +1,6 @@ use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::parse_uuid; +use crate::validate::{parse_uuid, sdk_err}; /// List DM conversations by querying kind:41010 (DM open) events authored by us. pub async fn cmd_list_dms(client: &SproutClient, limit: Option) -> Result<(), CliError> { @@ -21,9 +21,8 @@ pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<() if pubkeys.is_empty() || pubkeys.len() > 8 { return Err(CliError::Usage("--pubkey: must provide 1–8 pubkeys".into())); } - let refs: Vec<&str> = pubkeys.iter().map(|s| s.as_str()).collect(); - let builder = sprout_sdk::build_dm_open(&refs) - .map_err(|e| CliError::Other(format!("build_dm_open failed: {e}")))?; + let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect(); + let builder = sprout_sdk::build_dm_open(&refs).map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -39,8 +38,7 @@ pub async fn cmd_add_dm_member( ) -> Result<(), CliError> { let channel_uuid = parse_uuid(channel_id)?; - let builder = sprout_sdk::build_dm_add_member(channel_uuid, pubkey) - .map_err(|e| CliError::Other(format!("build_dm_add_member failed: {e}")))?; + let builder = sprout_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/sprout-cli/src/commands/users.rs b/crates/sprout-cli/src/commands/users.rs index 280e9987f..b4e638b83 100644 --- a/crates/sprout-cli/src/commands/users.rs +++ b/crates/sprout-cli/src/commands/users.rs @@ -218,8 +218,7 @@ pub async fn cmd_get_presence(client: &SproutClient, pubkeys_csv: &str) -> Resul /// This will fail until the CLI gains a WS publish path. The kind is correct /// per the protocol spec (KIND_PRESENCE_UPDATE = 20001). pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), CliError> { - let builder = sprout_sdk::build_presence_update(status) - .map_err(|e| CliError::Other(format!("build_presence_update failed: {e}")))?; + let builder = sprout_sdk::build_presence_update(status).map_err(crate::validate::sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/sprout-cli/src/commands/workflows.rs b/crates/sprout-cli/src/commands/workflows.rs index 8704092f3..57fb0bc67 100644 --- a/crates/sprout-cli/src/commands/workflows.rs +++ b/crates/sprout-cli/src/commands/workflows.rs @@ -2,7 +2,7 @@ use sha2::{Digest, Sha256}; use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::{parse_uuid, read_or_stdin, validate_uuid}; +use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid}; // --------------------------------------------------------------------------- // Read commands — POST /query @@ -63,10 +63,9 @@ pub async fn cmd_create_workflow( let channel_uuid = parse_uuid(channel_id)?; let yaml_definition = read_or_stdin(yaml)?; - // Generate a unique d-tag for this workflow - let workflow_id = uuid::Uuid::new_v4().to_string(); - let builder = sprout_sdk::build_workflow_def(channel_uuid, &workflow_id, &yaml_definition) - .map_err(|e| CliError::Other(format!("build_workflow_def failed: {e}")))?; + let workflow_id = uuid::Uuid::new_v4(); + let builder = sprout_sdk::build_workflow_def(channel_uuid, workflow_id, &yaml_definition) + .map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -77,14 +76,16 @@ pub async fn cmd_create_workflow( /// Update a workflow — sign and submit an updated kind:30620 event with same d-tag. pub async fn cmd_update_workflow( client: &SproutClient, + channel_id: &str, workflow_id: &str, yaml: &str, ) -> Result<(), CliError> { - validate_uuid(workflow_id)?; + let channel_uuid = parse_uuid(channel_id)?; + let wf_uuid = parse_uuid(workflow_id)?; let yaml_definition = read_or_stdin(yaml)?; - let builder = sprout_sdk::build_workflow_update(workflow_id, &yaml_definition) - .map_err(|e| CliError::Other(format!("build_workflow_update failed: {e}")))?; + let builder = sprout_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition) + .map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -94,11 +95,11 @@ pub async fn cmd_update_workflow( /// Delete a workflow — sign and submit a kind:5 deletion event. pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> { - validate_uuid(workflow_id)?; + let wf_uuid = parse_uuid(workflow_id)?; let keys = client.keys(); - let builder = sprout_sdk::build_workflow_delete(&keys.public_key().to_hex(), workflow_id) - .map_err(|e| CliError::Other(format!("build_workflow_delete failed: {e}")))?; + let builder = + sprout_sdk::build_workflow_delete(&keys.public_key().to_hex(), wf_uuid).map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -111,10 +112,9 @@ pub async fn cmd_trigger_workflow( client: &SproutClient, workflow_id: &str, ) -> Result<(), CliError> { - validate_uuid(workflow_id)?; + let wf_uuid = parse_uuid(workflow_id)?; - let builder = sprout_sdk::build_workflow_trigger(workflow_id) - .map_err(|e| CliError::Other(format!("build_workflow_trigger failed: {e}")))?; + let builder = sprout_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -135,8 +135,8 @@ pub async fn cmd_approve_step( // The relay expects d-tag = hex(SHA256(token)), not the raw token UUID. let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes())); - let builder = sprout_sdk::build_workflow_approval(&token_hash, approved, content) - .map_err(|e| CliError::Other(format!("build_workflow_approval failed: {e}")))?; + let builder = + sprout_sdk::build_workflow_approval(&token_hash, approved, content).map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index bc14218b7..4cf2b18ae 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -377,6 +377,8 @@ enum Cmd { }, /// Update a workflow UpdateWorkflow { + #[arg(long)] + channel: String, #[arg(long)] workflow: String, #[arg(long)] @@ -819,9 +821,11 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::CreateWorkflow { channel, yaml } => { commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await } - Cmd::UpdateWorkflow { workflow, yaml } => { - commands::workflows::cmd_update_workflow(&client, &workflow, &yaml).await - } + Cmd::UpdateWorkflow { + channel, + workflow, + yaml, + } => commands::workflows::cmd_update_workflow(&client, &channel, &workflow, &yaml).await, Cmd::DeleteWorkflow { workflow } => { commands::workflows::cmd_delete_workflow(&client, &workflow).await } diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs index b18d4ec37..0903a4088 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -146,6 +146,16 @@ pub fn infer_language(file_path: &str) -> Option { Some(lang.to_string()) } +/// Map `SdkError` to the appropriate `CliError` variant. +/// +/// `InvalidInput` is a user error (exit 1), everything else is internal (exit 4). +pub fn sdk_err(e: sprout_sdk::SdkError) -> CliError { + match e { + sprout_sdk::SdkError::InvalidInput(msg) => CliError::Usage(msg), + other => CliError::Other(other.to_string()), + } +} + /// Read content from a string value or stdin if the value is "-". pub fn read_or_stdin(value: &str) -> Result { if value == "-" { diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs index 2e05899c1..7c91e9303 100644 --- a/crates/sprout-sdk/src/builders.rs +++ b/crates/sprout-sdk/src/builders.rs @@ -1,4 +1,4 @@ -//! The 25 typed event builder functions. +//! Typed event builder functions (38 builders). //! //! All functions return `Result`. //! The caller signs: `builder.sign_with_keys(&keys)?`. @@ -900,22 +900,21 @@ pub fn build_repo_announcement( )) } -// --------------------------------------------------------------------------- -// Workflow builders -// --------------------------------------------------------------------------- +// ── Builder 31: build_workflow_def ──────────────────────────────────────────── /// Build a workflow definition event (kind 30620). /// /// - `channel_id`: the channel this workflow belongs to (h-tag) -/// - `workflow_id`: unique d-tag identifier for this workflow +/// - `workflow_id`: unique workflow UUID (d-tag) /// - `yaml`: workflow YAML definition as content pub fn build_workflow_def( channel_id: Uuid, - workflow_id: &str, + workflow_id: Uuid, yaml: &str, ) -> Result { + check_content(yaml, 64 * 1024)?; let tags = vec![ - tag(&["d", workflow_id])?, + tag(&["d", &workflow_id.to_string()])?, tag(&["h", &channel_id.to_string()])?, ]; Ok(EventBuilder::new( @@ -925,12 +924,23 @@ pub fn build_workflow_def( )) } +// ── Builder 32: build_workflow_update ───────────────────────────────────────── + /// Build a workflow update event (kind 30620) for an existing workflow. /// /// Updates an existing workflow definition in-place via the parameterized /// replaceable event mechanism — same d-tag overwrites the previous version. -pub fn build_workflow_update(workflow_id: &str, yaml: &str) -> Result { - let tags = vec![tag(&["d", workflow_id])?]; +/// The h-tag (channel scope) is required by the relay for authorization. +pub fn build_workflow_update( + channel_id: Uuid, + workflow_id: Uuid, + yaml: &str, +) -> Result { + check_content(yaml, 64 * 1024)?; + let tags = vec![ + tag(&["d", &workflow_id.to_string()])?, + tag(&["h", &channel_id.to_string()])?, + ]; Ok(EventBuilder::new( Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml, @@ -938,15 +948,21 @@ pub fn build_workflow_update(workflow_id: &str, yaml: &str) -> Result:`. +/// The `a`-tag addresses the parameterized replaceable event +/// `::`. pub fn build_workflow_delete( author_pubkey: &str, - workflow_id: &str, + workflow_id: Uuid, ) -> Result { let pk = check_pubkey_hex(author_pubkey, "author_pubkey")?; - let tags = vec![tag(&["a", &format!("30620:{pk}:{workflow_id}")])?]; + let tags = vec![tag(&[ + "a", + &format!("{}:{pk}:{workflow_id}", KIND_WORKFLOW_DEF), + ])?]; Ok(EventBuilder::new( Kind::Custom(KIND_DELETION as u16), "", @@ -954,9 +970,11 @@ pub fn build_workflow_delete( )) } +// ── Builder 34: build_workflow_trigger ──────────────────────────────────────── + /// Build a workflow trigger event (kind 46020). -pub fn build_workflow_trigger(workflow_id: &str) -> Result { - let tags = vec![tag(&["d", workflow_id])?]; +pub fn build_workflow_trigger(workflow_id: Uuid) -> Result { + let tags = vec![tag(&["d", &workflow_id.to_string()])?]; Ok(EventBuilder::new( Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), "", @@ -964,9 +982,12 @@ pub fn build_workflow_trigger(workflow_id: &str) -> Result Result { + if token_hash.len() != 64 || !token_hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput( + "token_hash must be a 64-character hex SHA-256 digest".into(), + )); + } let kind = if approved { KIND_APPROVAL_GRANT } else { @@ -983,13 +1009,11 @@ pub fn build_workflow_approval( Ok(EventBuilder::new(Kind::Custom(kind as u16), note, tags)) } -// --------------------------------------------------------------------------- -// DM builders -// --------------------------------------------------------------------------- +// ── Builder 36: build_dm_open ──────────────────────────────────────────────── /// Build a DM open event (kind 41010). /// -/// `pubkeys` must be 1-8 hex-encoded pubkeys to include in the DM conversation. +/// `pubkeys` must be 1–8 hex-encoded pubkeys to include in the DM conversation. pub fn build_dm_open(pubkeys: &[&str]) -> Result { if pubkeys.is_empty() || pubkeys.len() > 8 { return Err(SdkError::InvalidInput( @@ -1008,6 +1032,8 @@ pub fn build_dm_open(pubkeys: &[&str]) -> Result { )) } +// ── Builder 37: build_dm_add_member ────────────────────────────────────────── + /// Build a DM add-member event (kind 41011). pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result { let pk = check_pubkey_hex(pubkey, "pubkey")?; @@ -1019,13 +1045,13 @@ pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result Result { match status { "online" | "away" | "offline" => {} @@ -1038,7 +1064,7 @@ pub fn build_presence_update(status: &str) -> Result { let tags = vec![tag(&["status", status])?]; Ok(EventBuilder::new( Kind::Custom(KIND_PRESENCE_UPDATE as u16), - "", + status, tags, )) } @@ -2111,4 +2137,180 @@ mod tests { assert_eq!(vals[0], "https://relay.example.com/git/abc/multi-clone"); assert_eq!(vals[1], "ssh://git@github.com/org/multi-clone.git"); } + + // ── Builder 31: build_workflow_def ─────────────────────────────────────── + + #[test] + fn workflow_def_happy_path() { + let cid = uuid(); + let wid = uuid(); + let ev = sign(build_workflow_def(cid, wid, "name: test\ntrigger:\n on: webhook").unwrap()); + assert_eq!(ev.kind.as_u16(), 30620); + assert!(has_tag(&ev, "d", &wid.to_string())); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(ev.content.contains("name: test")); + } + + #[test] + fn workflow_def_rejects_oversized_yaml() { + let big = "x".repeat(65 * 1024); + let err = build_workflow_def(uuid(), uuid(), &big).unwrap_err(); + assert!(matches!(err, SdkError::ContentTooLarge { .. })); + } + + // ── Builder 32: build_workflow_update ──────────────────────────────────── + + #[test] + fn workflow_update_includes_h_tag() { + let cid = uuid(); + let wid = uuid(); + let ev = sign(build_workflow_update(cid, wid, "name: updated").unwrap()); + assert_eq!(ev.kind.as_u16(), 30620); + assert!(has_tag(&ev, "d", &wid.to_string())); + assert!(has_tag(&ev, "h", &cid.to_string())); + } + + #[test] + fn workflow_update_rejects_oversized_yaml() { + let big = "x".repeat(65 * 1024); + let err = build_workflow_update(uuid(), uuid(), &big).unwrap_err(); + assert!(matches!(err, SdkError::ContentTooLarge { .. })); + } + + // ── Builder 33: build_workflow_delete ──────────────────────────────────── + + #[test] + fn workflow_delete_happy_path() { + let pk = "a".repeat(64); + let wid = uuid(); + let ev = sign(build_workflow_delete(&pk, wid).unwrap()); + assert_eq!(ev.kind.as_u16(), 5); + let a_vals = tag_values(&ev, "a"); + assert_eq!(a_vals.len(), 1); + assert!(a_vals[0].starts_with("30620:")); + assert!(a_vals[0].contains(&wid.to_string())); + } + + #[test] + fn workflow_delete_rejects_bad_pubkey() { + let err = build_workflow_delete("bad", uuid()).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + // ── Builder 34: build_workflow_trigger ─────────────────────────────────── + + #[test] + fn workflow_trigger_happy_path() { + let wid = uuid(); + let ev = sign(build_workflow_trigger(wid).unwrap()); + assert_eq!(ev.kind.as_u16(), 46020); + assert!(has_tag(&ev, "d", &wid.to_string())); + } + + // ── Builder 35: build_workflow_approval ────────────────────────────────── + + #[test] + fn workflow_approval_grant() { + let hash = "a".repeat(64); + let ev = sign(build_workflow_approval(&hash, true, "lgtm").unwrap()); + assert_eq!(ev.kind.as_u16(), 46030); + assert!(has_tag(&ev, "d", &hash)); + assert_eq!(ev.content, "lgtm"); + } + + #[test] + fn workflow_approval_deny() { + let hash = "b".repeat(64); + let ev = sign(build_workflow_approval(&hash, false, "").unwrap()); + assert_eq!(ev.kind.as_u16(), 46031); + } + + #[test] + fn workflow_approval_rejects_bad_token_hash() { + let err = build_workflow_approval("not-hex", true, "").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn workflow_approval_rejects_short_hash() { + let short = "a".repeat(32); + let err = build_workflow_approval(&short, true, "").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + // ── Builder 36: build_dm_open ─────────────────────────────────────────── + + #[test] + fn dm_open_happy_path() { + let pk = "a".repeat(64); + let ev = sign(build_dm_open(&[&pk]).unwrap()); + assert_eq!(ev.kind.as_u16(), 41010); + assert!(has_tag(&ev, "p", &pk)); + } + + #[test] + fn dm_open_rejects_empty_pubkeys() { + let err = build_dm_open(&[]).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn dm_open_rejects_over_8_pubkeys() { + let pk = "a".repeat(64); + let pks: Vec<&str> = vec![pk.as_str(); 9]; + let err = build_dm_open(&pks).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn dm_open_rejects_bad_pubkey() { + let err = build_dm_open(&["bad-hex"]).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + // ── Builder 37: build_dm_add_member ───────────────────────────────────── + + #[test] + fn dm_add_member_happy_path() { + let cid = uuid(); + let pk = "b".repeat(64); + let ev = sign(build_dm_add_member(cid, &pk).unwrap()); + assert_eq!(ev.kind.as_u16(), 41011); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(has_tag(&ev, "p", &pk)); + } + + #[test] + fn dm_add_member_rejects_bad_pubkey() { + let err = build_dm_add_member(uuid(), "short").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + // ── Builder 38: build_presence_update ──────────────────────────────────── + + #[test] + fn presence_update_content_is_status() { + let ev = sign(build_presence_update("online").unwrap()); + assert_eq!(ev.kind.as_u16(), 20001); + assert_eq!(ev.content, "online"); + assert!(has_tag(&ev, "status", "online")); + } + + #[test] + fn presence_update_away() { + let ev = sign(build_presence_update("away").unwrap()); + assert_eq!(ev.content, "away"); + } + + #[test] + fn presence_update_offline() { + let ev = sign(build_presence_update("offline").unwrap()); + assert_eq!(ev.content, "offline"); + } + + #[test] + fn presence_update_rejects_invalid_status() { + let err = build_presence_update("dnd").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } }