From 5e189b3cb7e5df1fbcd508a37e2f3c71d370944e Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 4 Aug 2026 22:52:30 -0400 Subject: [PATCH 1/2] fix(cli): emit diagnostic when archive/unarchive owner-auth extraction fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `buzz agents archive` or `buzz agents unarchive` runs for a non-self target, `resolve_auth` fetches the target's kind:0 and looks for a NIP-OA `auth` tag. If the tag is absent or invalid the CLI was silently sending a bare request, which caused the relay to respond with the opaque error `400: missing auth tag` — exactly what Will hit when archiving duplicate agent instances. Four distinct causes can prevent attestation: 1. No kind:0 profile found for the target pubkey. 2. Target kind:0 has no `tags` array. 3. kind:0 has zero or multiple `auth`-labelled tags (ambiguous ownership). 4. Sole `auth` tag is structurally malformed, or its owner pubkey does not match the signer (the diagnostic prints the actual owner so the user can identify the mismatch). Each cause now emits a distinct warning to stderr naming what was wrong, then falls through to send the bare request — relay admins can still succeed without owner attestation. The self path (target == signer) is unchanged and silent. Also fixes the `resolve_auth` doc-comment, which incorrectly described the degradation as silent after the non-silent network-error behaviour was added. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/agents.rs | 212 ++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 8 deletions(-) diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 58564a45c2..833440096d 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -163,12 +163,13 @@ fn require_owner(client: &BuzzClient) -> Result { /// Resolve the optional NIP-OA `auth` tag for archive/unarchive requests. /// /// Mirrors the desktop's `maybe_owner_auth_tag`: -/// - `target == signer`: self path — no auth needed → `Ok(None)`. +/// - `target == signer`: self path — no auth needed → `Ok(None)`, silent. /// - Otherwise: fetch target's kind:0, look for an `auth` tag whose owner -/// (index 1) matches the signer. Return it when present; `Ok(None)` when -/// absent or structurally malformed. Query/network failures surface as -/// `Err` — silent degradation to bare would make the relay reject the -/// request with a misleading error. +/// (index 1) matches the signer. Return it when present. When the tag is +/// absent or cannot be extracted, emit a structured diagnostic to stderr +/// naming the exact cause and then return `Ok(None)` — the bare request +/// is still sent so relay admins can succeed without owner attestation. +/// Query/network failures surface as `Err`. async fn resolve_auth( client: &BuzzClient, target_hex: &str, @@ -186,13 +187,95 @@ async fn resolve_auth( .map_err(|e| CliError::Other(format!("invalid kind:0 query response: {e}")))?; let event = match events.into_iter().next() { Some(e) => e, - None => return Ok(None), + None => { + eprintln!( + "warning: no kind:0 profile found for target {target_hex} — \ + proceeding without owner attestation; this succeeds only if your key is a relay admin" + ); + return Ok(None); + } }; let tags = match event.get("tags").and_then(|v| v.as_array()) { Some(t) => t, - None => return Ok(None), + None => { + eprintln!( + "warning: target {target_hex} kind:0 has no tags array — \ + proceeding without owner attestation; this succeeds only if your key is a relay admin" + ); + return Ok(None); + } }; - Ok(extract_owner_auth_tag(tags, signer_hex)) + match extract_owner_auth_tag(tags, signer_hex) { + Some(tag) => Ok(Some(tag)), + None => { + let diag = describe_auth_failure(tags, signer_hex); + eprintln!( + "warning: {diag} — \ + proceeding without owner attestation; this succeeds only if your key is a relay admin" + ); + Ok(None) + } + } +} + +/// Describe why `extract_owner_auth_tag` returned `None` for a non-empty tags +/// array. Called only in the failure branch of `resolve_auth` to produce a +/// human-readable diagnostic. Keeps `extract_owner_auth_tag` pure. +fn describe_auth_failure(tags: &[serde_json::Value], signer_hex: &str) -> String { + let auth_tags: Vec<&serde_json::Value> = tags + .iter() + .filter(|tag| { + tag.as_array() + .and_then(|elems| elems.first()) + .and_then(|v| v.as_str()) + == Some("auth") + }) + .collect(); + match auth_tags.len() { + 0 => "target kind:0 has no \"auth\" tag".to_owned(), + n if n > 1 => format!( + "target kind:0 has {n} \"auth\" tags (expected exactly 1) — ambiguous ownership" + ), + _ => { + // Exactly one auth tag. Determine the structural failure. + let elems = match auth_tags[0].as_array() { + Some(e) => e, + None => return "sole \"auth\" tag is not a JSON array".to_owned(), + }; + if elems.len() != 4 { + return format!( + "sole \"auth\" tag has {} element(s) (expected 4: label, owner, conditions, sig)", + elems.len() + ); + } + // Check each element is a string before inspecting values. + let owner_val = &elems[1]; + let sig_val = &elems[3]; + if elems[0].as_str().is_none() + || owner_val.as_str().is_none() + || elems[2].as_str().is_none() + || sig_val.as_str().is_none() + { + return "sole \"auth\" tag contains a non-string element".to_owned(); + } + let owner = owner_val.as_str().unwrap(); + let sig = sig_val.as_str().unwrap(); + // Owner hex validation. + if owner.len() != 64 || !owner.chars().all(|c| c.is_ascii_hexdigit()) { + return format!( + "sole \"auth\" tag owner field is not a valid 64-hex pubkey: {owner}" + ); + } + // Sig hex validation. + if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) { + return "sole \"auth\" tag sig field is not a valid 128-hex signature".to_owned(); + } + // Owner mismatch — the tag is structurally valid but names a different owner. + format!( + "sole \"auth\" tag names owner {owner} which does not match your key {signer_hex}" + ) + } + } } /// Pure extraction helper: require exactly one kind:0 tag whose first @@ -521,6 +604,119 @@ mod tests { assert!(extract_owner_auth_tag(&tags, &signer).is_none()); } + // --- (c) auth-failure diagnostic: describe_auth_failure --- + // + // Covers the four cause categories Paul named. Each test asserts the + // diagnostic string contains the key identifying phrase so tests stay + // readable without tying to exact wording. + + #[test] + fn auth_failure_no_auth_tag_names_absent_tag() { + // Case 3 (zero auth tags): kind:0 has tags but none labelled "auth". + let signer = hex64('a'); + let tags = vec![json!(["p", hex64('b')]), json!(["e", hex64('c')])]; + let msg = describe_auth_failure(&tags, &signer); + assert!( + msg.contains("no \"auth\" tag"), + "expected 'no auth tag' message, got: {msg}" + ); + } + + #[test] + fn auth_failure_empty_tags_names_absent_tag() { + // Edge case: tags array exists but is empty — treated as no auth tag. + let signer = hex64('a'); + let msg = describe_auth_failure(&[], &signer); + assert!( + msg.contains("no \"auth\" tag"), + "expected 'no auth tag' message, got: {msg}" + ); + } + + #[test] + fn auth_failure_duplicate_auth_tags_names_count() { + // Case 3 (>1 auth tags): ambiguous ownership. + let signer = hex64('a'); + let sig = hex128('b'); + let tags = vec![ + json!(["auth", signer, "conditions", sig]), + json!(["auth", signer, "conditions", sig]), + ]; + let msg = describe_auth_failure(&tags, &signer); + assert!( + msg.contains('2') && msg.contains("ambiguous"), + "expected count + ambiguous message, got: {msg}" + ); + } + + #[test] + fn auth_failure_malformed_wrong_arity_names_element_count() { + // Case 4: sole auth tag has wrong arity (3 elements instead of 4). + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, "conditions"])]; + let msg = describe_auth_failure(&tags, &signer); + assert!( + msg.contains("element") && msg.contains('3'), + "expected element-count message, got: {msg}" + ); + } + + #[test] + fn auth_failure_malformed_non_string_element() { + // Case 4: sole auth tag contains a non-string element (numeric conditions). + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, 42, hex128('b')])]; + let msg = describe_auth_failure(&tags, &signer); + assert!( + msg.contains("non-string"), + "expected non-string message, got: {msg}" + ); + } + + #[test] + fn auth_failure_malformed_non_hex_owner_names_owner() { + // Case 4: owner field is not valid 64-hex. + let bad_owner = "z".repeat(64); + let tags = vec![json!(["auth", bad_owner, "", hex128('a')])]; + let msg = describe_auth_failure(&tags, &bad_owner); + assert!( + msg.contains("owner field") && msg.contains("not a valid 64-hex"), + "expected owner-hex message, got: {msg}" + ); + } + + #[test] + fn auth_failure_malformed_non_hex_sig_names_sig() { + // Case 4: sig field is not valid 128-hex. + let signer = hex64('a'); + let bad_sig = "z".repeat(128); + let tags = vec![json!(["auth", signer, "", bad_sig])]; + let msg = describe_auth_failure(&tags, &signer); + assert!( + msg.contains("sig field") && msg.contains("not a valid 128-hex"), + "expected sig-hex message, got: {msg}" + ); + } + + #[test] + fn auth_failure_owner_mismatch_names_actual_owner_and_signer() { + // Case 4: structurally valid tag but owner pubkey ≠ signer — the + // diagnostic must print the actual owner so the user can act on it. + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let tags = vec![json!(["auth", actual_owner, "conditions", sig])]; + let msg = describe_auth_failure(&tags, &signer); + assert!( + msg.contains(&actual_owner) && msg.contains(&signer), + "expected both owner and signer pubkeys in message, got: {msg}" + ); + assert!( + msg.contains("does not match"), + "expected mismatch language, got: {msg}" + ); + } + // --- (d) NIP-11 self normalization: normalize_relay_self_hex --- #[test] From c17eead93163c2866ea5fdf05ff1f55ace01d2b7 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 4 Aug 2026 23:10:58 -0400 Subject: [PATCH 2/2] fix(cli): structured JSON warnings, typed classifier, warn-sink seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three IMPORTANT findings from Thufir's pass-1 review: 1. JSON warnings, not prose Replace eprintln! calls with writeln!(warn_sink, ...) emitting exactly one {"warning":"..."} JSON object per fallback — consistent with the CLI's documented structured-stderr contract (README.md, VISION.md) and the precedent in channels.rs:597. relay-supplied values (target pubkey, actual owner pubkey) pass through serde_json serialization so no unescaped text reaches the output stream. 2. Injected warn-sink seam Thread 'warn_sink: &mut dyn std::io::Write' through resolve_auth (same pattern as finalize_roster_resolution in channels.rs). Production callers pass &mut std::io::stderr(); tests pass &mut Vec. New boundary tests prove each of the four fallback causes emits exactly one parseable JSON warning, and that the success path emits nothing. 3. Single typed classifier Replace the parallel describe_auth_failure mirror with AuthFailure enum + classify_owner_auth_tag returning Result<[String;4], AuthFailure>. extract_owner_auth_tag is reduced to a #[cfg(test)] .ok() wrapper for the existing option-style tests; the unreachable 'not a JSON array' branch is eliminated. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/agents.rs | 543 ++++++++++++++++--------- 1 file changed, 352 insertions(+), 191 deletions(-) diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 833440096d..fcdee15060 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -93,7 +93,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { validate_hex64(&target_pubkey)?; let signer_hex = client.keys().public_key().to_hex(); - let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; + let auth = + resolve_auth(client, &target_pubkey, &signer_hex, &mut std::io::stderr()).await?; let builder = build_archive_identity_request( &target_pubkey, &content, @@ -124,7 +125,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { validate_hex64(&target_pubkey)?; let signer_hex = client.keys().public_key().to_hex(); - let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; + let auth = + resolve_auth(client, &target_pubkey, &signer_hex, &mut std::io::stderr()).await?; let builder = build_unarchive_identity_request( &target_pubkey, &content, @@ -160,133 +162,63 @@ fn require_owner(client: &BuzzClient) -> Result { PublicKey::parse(&hex).map_err(|e| CliError::Auth(format!("invalid owner attestation: {e}"))) } -/// Resolve the optional NIP-OA `auth` tag for archive/unarchive requests. +/// Typed reason why NIP-OA owner-auth could not be extracted from a kind:0. /// -/// Mirrors the desktop's `maybe_owner_auth_tag`: -/// - `target == signer`: self path — no auth needed → `Ok(None)`, silent. -/// - Otherwise: fetch target's kind:0, look for an `auth` tag whose owner -/// (index 1) matches the signer. Return it when present. When the tag is -/// absent or cannot be extracted, emit a structured diagnostic to stderr -/// naming the exact cause and then return `Ok(None)` — the bare request -/// is still sent so relay admins can succeed without owner attestation. -/// Query/network failures surface as `Err`. -async fn resolve_auth( - client: &BuzzClient, - target_hex: &str, - signer_hex: &str, -) -> Result, CliError> { - if target_hex.eq_ignore_ascii_case(signer_hex) { - return Ok(None); - } - let filter = json!({"kinds": [0], "authors": [target_hex], "limit": 1}); - let raw = client - .query(&filter) - .await - .map_err(|e| CliError::Other(format!("failed to fetch target kind:0: {e}")))?; - let events: Vec = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("invalid kind:0 query response: {e}")))?; - let event = match events.into_iter().next() { - Some(e) => e, - None => { - eprintln!( - "warning: no kind:0 profile found for target {target_hex} — \ - proceeding without owner attestation; this succeeds only if your key is a relay admin" - ); - return Ok(None); - } - }; - let tags = match event.get("tags").and_then(|v| v.as_array()) { - Some(t) => t, - None => { - eprintln!( - "warning: target {target_hex} kind:0 has no tags array — \ - proceeding without owner attestation; this succeeds only if your key is a relay admin" - ); - return Ok(None); - } - }; - match extract_owner_auth_tag(tags, signer_hex) { - Some(tag) => Ok(Some(tag)), - None => { - let diag = describe_auth_failure(tags, signer_hex); - eprintln!( - "warning: {diag} — \ - proceeding without owner attestation; this succeeds only if your key is a relay admin" - ); - Ok(None) - } - } +/// Produced by [`classify_owner_auth_tag`] and formatted into a JSON warning +/// by [`resolve_auth`]. One variant per distinguishable failure cause so the +/// diagnostic is always accurate and never duplicates validation logic. +#[derive(Debug, PartialEq)] +enum AuthFailure { + /// kind:0 has no `tags` array or the array is empty of `auth`-labelled entries. + NoAuthTag, + /// kind:0 has more than one `auth`-labelled tag; count included. + AmbiguousAuthTag(usize), + /// Sole `auth` tag has wrong element count; actual count included. + WrongArity(usize), + /// Sole `auth` tag contains a non-string element. + NonStringElement, + /// Sole `auth` tag owner field is not a valid 64-hex pubkey; value included. + InvalidOwnerHex(String), + /// Sole `auth` tag sig field is not a valid 128-hex signature. + InvalidSigHex, + /// Tag is structurally valid but names a different owner; actual owner included. + OwnerMismatch(String), } -/// Describe why `extract_owner_auth_tag` returned `None` for a non-empty tags -/// array. Called only in the failure branch of `resolve_auth` to produce a -/// human-readable diagnostic. Keeps `extract_owner_auth_tag` pure. -fn describe_auth_failure(tags: &[serde_json::Value], signer_hex: &str) -> String { - let auth_tags: Vec<&serde_json::Value> = tags - .iter() - .filter(|tag| { - tag.as_array() - .and_then(|elems| elems.first()) - .and_then(|v| v.as_str()) - == Some("auth") - }) - .collect(); - match auth_tags.len() { - 0 => "target kind:0 has no \"auth\" tag".to_owned(), - n if n > 1 => format!( - "target kind:0 has {n} \"auth\" tags (expected exactly 1) — ambiguous ownership" - ), - _ => { - // Exactly one auth tag. Determine the structural failure. - let elems = match auth_tags[0].as_array() { - Some(e) => e, - None => return "sole \"auth\" tag is not a JSON array".to_owned(), - }; - if elems.len() != 4 { - return format!( - "sole \"auth\" tag has {} element(s) (expected 4: label, owner, conditions, sig)", - elems.len() - ); +impl AuthFailure { + /// Human-readable description suitable for the `"warning"` JSON field. + fn message(&self) -> String { + match self { + AuthFailure::NoAuthTag => "target kind:0 has no \"auth\" tag".to_owned(), + AuthFailure::AmbiguousAuthTag(n) => format!( + "target kind:0 has {n} \"auth\" tags (expected exactly 1) — ambiguous ownership" + ), + AuthFailure::WrongArity(n) => format!( + "sole \"auth\" tag has {n} element(s) (expected 4: label, owner, conditions, sig)" + ), + AuthFailure::NonStringElement => { + "sole \"auth\" tag contains a non-string element".to_owned() } - // Check each element is a string before inspecting values. - let owner_val = &elems[1]; - let sig_val = &elems[3]; - if elems[0].as_str().is_none() - || owner_val.as_str().is_none() - || elems[2].as_str().is_none() - || sig_val.as_str().is_none() - { - return "sole \"auth\" tag contains a non-string element".to_owned(); + AuthFailure::InvalidOwnerHex(v) => { + format!("sole \"auth\" tag owner field is not a valid 64-hex pubkey: {v}") } - let owner = owner_val.as_str().unwrap(); - let sig = sig_val.as_str().unwrap(); - // Owner hex validation. - if owner.len() != 64 || !owner.chars().all(|c| c.is_ascii_hexdigit()) { - return format!( - "sole \"auth\" tag owner field is not a valid 64-hex pubkey: {owner}" - ); + AuthFailure::InvalidSigHex => { + "sole \"auth\" tag sig field is not a valid 128-hex signature".to_owned() } - // Sig hex validation. - if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) { - return "sole \"auth\" tag sig field is not a valid 128-hex signature".to_owned(); + AuthFailure::OwnerMismatch(actual) => { + format!("sole \"auth\" tag names owner {actual} which does not match your key") } - // Owner mismatch — the tag is structurally valid but names a different owner. - format!( - "sole \"auth\" tag names owner {owner} which does not match your key {signer_hex}" - ) } } } -/// Pure extraction helper: require exactly one kind:0 tag whose first -/// element is `"auth"` (a set-level rule — a valid tag alongside a second -/// malformed or duplicate `auth`-labeled tag is bare, not the valid one), -/// then structurally validate that sole tag as -/// `["auth", owner, conditions, sig]` matching `signer_hex`. -/// -/// Malformed tags (wrong arity, non-string elements, non-hex fields) are -/// silently skipped — the contract is "bare" (None), not error. -fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Option<[String; 4]> { +/// Single classifier: either extract the auth tag or return the typed reason +/// for failure. [`extract_owner_auth_tag`] is a thin `.ok()` wrapper kept for +/// the existing tests that assert on `Option`. +fn classify_owner_auth_tag( + tags: &[serde_json::Value], + signer_hex: &str, +) -> Result<[String; 4], AuthFailure> { let auth_tags: Vec<&serde_json::Value> = tags .iter() .filter(|tag| { @@ -296,29 +228,33 @@ fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Optio == Some("auth") }) .collect(); - if auth_tags.len() != 1 { - return None; + match auth_tags.len() { + 0 => return Err(AuthFailure::NoAuthTag), + n if n > 1 => return Err(AuthFailure::AmbiguousAuthTag(n)), + _ => {} } - let elems = auth_tags[0].as_array()?; + // Exactly one auth tag. + let elems = auth_tags[0] + .as_array() + .ok_or(AuthFailure::NonStringElement)?; if elems.len() != 4 { - return None; + return Err(AuthFailure::WrongArity(elems.len())); + } + let label = elems[0].as_str().ok_or(AuthFailure::NonStringElement)?; + let owner = elems[1].as_str().ok_or(AuthFailure::NonStringElement)?; + let conditions = elems[2].as_str().ok_or(AuthFailure::NonStringElement)?; + let sig = elems[3].as_str().ok_or(AuthFailure::NonStringElement)?; + if owner.len() != 64 || !owner.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AuthFailure::InvalidOwnerHex(owner.to_owned())); + } + if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AuthFailure::InvalidSigHex); } - let label = elems[0].as_str()?; - let owner = elems[1].as_str()?; if !owner.eq_ignore_ascii_case(signer_hex) { - return None; - } - let conditions = elems[2].as_str()?; - let sig = elems[3].as_str()?; - if owner.len() != 64 - || !owner.chars().all(|c| c.is_ascii_hexdigit()) - || sig.len() != 128 - || !sig.chars().all(|c| c.is_ascii_hexdigit()) - { - return None; - } - Some([ + return Err(AuthFailure::OwnerMismatch(owner.to_owned())); + } + Ok([ label.to_owned(), owner.to_owned(), conditions.to_owned(), @@ -326,6 +262,108 @@ fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Optio ]) } +/// Pure sync core of auth resolution: given a fetched kind:0 profile (or +/// `None` when no event was found), either return the extracted auth tag or +/// emit one `{"warning":"..."}` JSON line to `warn_sink` and return `None`. +/// +/// Separated from [`resolve_auth`] so unit tests can call this directly with +/// a `Vec` sink and assert on exactly what hits the wire — without needing +/// a live `BuzzClient` or async runtime. +/// +/// Three warning branches, one success path: +/// 1. `profile == None` → no kind:0 found for target. +/// 2. `profile.get("tags")` absent or non-array → no tags array. +/// 3. [`classify_owner_auth_tag`] returns `Err` → typed failure reason. +/// 4. `classify_owner_auth_tag` returns `Ok` → `Some(tag)`, no warning. +fn resolve_auth_from_profile( + profile: Option<&serde_json::Value>, + target_hex: &str, + signer_hex: &str, + warn_sink: &mut dyn std::io::Write, +) -> Option<[String; 4]> { + let event = match profile { + Some(e) => e, + None => { + let msg = format!( + "no kind:0 profile found for target {target_hex}; \ + proceeding without owner attestation — this succeeds only if your key is a relay admin" + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + return None; + } + }; + let tags = match event.get("tags").and_then(|v| v.as_array()) { + Some(t) => t, + None => { + let msg = format!( + "target {target_hex} kind:0 has no tags array; \ + proceeding without owner attestation — this succeeds only if your key is a relay admin" + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + return None; + } + }; + match classify_owner_auth_tag(tags, signer_hex) { + Ok(tag) => Some(tag), + Err(failure) => { + let msg = format!( + "{}; proceeding without owner attestation — \ + this succeeds only if your key is a relay admin", + failure.message() + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + None + } + } +} + +/// Resolve the optional NIP-OA `auth` tag for archive/unarchive requests. +/// +/// Mirrors the desktop's `maybe_owner_auth_tag`: +/// - `target == signer`: self path — no auth needed → `Ok(None)`, silent. +/// - Otherwise: fetch target's kind:0, delegate to [`resolve_auth_from_profile`] +/// which either returns the extracted tag or emits one `{"warning":"..."}` JSON +/// line to `warn_sink` and returns `None` — the bare request is still sent so +/// relay admins can succeed without owner attestation. Query/network failures +/// surface as `Err`. +async fn resolve_auth( + client: &BuzzClient, + target_hex: &str, + signer_hex: &str, + warn_sink: &mut dyn std::io::Write, +) -> Result, CliError> { + if target_hex.eq_ignore_ascii_case(signer_hex) { + return Ok(None); + } + let filter = json!({"kinds": [0], "authors": [target_hex], "limit": 1}); + let raw = client + .query(&filter) + .await + .map_err(|e| CliError::Other(format!("failed to fetch target kind:0: {e}")))?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid kind:0 query response: {e}")))?; + let profile = events.into_iter().next(); + Ok(resolve_auth_from_profile( + profile.as_ref(), + target_hex, + signer_hex, + warn_sink, + )) +} + +/// Pure extraction helper: require exactly one kind:0 tag whose first +/// element is `"auth"` (a set-level rule — a valid tag alongside a second +/// malformed or duplicate `auth`-labeled tag is bare, not the valid one), +/// then structurally validate that sole tag as +/// `["auth", owner, conditions, sig]` matching `signer_hex`. +/// +/// Thin wrapper around [`classify_owner_auth_tag`] that collapses the typed +/// failure reason to `None`. Malformed tags → `None`; valid tag → `Some`. +#[cfg(test)] +fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Option<[String; 4]> { + classify_owner_auth_tag(tags, signer_hex).ok() +} + /// Validate the NIP-11 relay-info `self` field is a 64-hex pubkey and /// normalize it to lowercase, so the archived-identities query filter and /// the author comparison in [`verify_archived_event`] agree regardless of @@ -604,119 +642,242 @@ mod tests { assert!(extract_owner_auth_tag(&tags, &signer).is_none()); } - // --- (c) auth-failure diagnostic: describe_auth_failure --- + // --- (c) auth-failure classifier: classify_owner_auth_tag --- // - // Covers the four cause categories Paul named. Each test asserts the - // diagnostic string contains the key identifying phrase so tests stay - // readable without tying to exact wording. + // Tests the typed failure taxonomy. Each case asserts the exact + // AuthFailure variant so a wrong classification causes a compile-time or + // assertion failure — not just a message-substring miss. #[test] - fn auth_failure_no_auth_tag_names_absent_tag() { - // Case 3 (zero auth tags): kind:0 has tags but none labelled "auth". + fn classify_no_auth_tag_returns_no_auth_tag() { + // Case 3 (zero auth tags): tags array has entries but none labelled "auth". let signer = hex64('a'); let tags = vec![json!(["p", hex64('b')]), json!(["e", hex64('c')])]; - let msg = describe_auth_failure(&tags, &signer); - assert!( - msg.contains("no \"auth\" tag"), - "expected 'no auth tag' message, got: {msg}" + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::NoAuthTag) ); } #[test] - fn auth_failure_empty_tags_names_absent_tag() { - // Edge case: tags array exists but is empty — treated as no auth tag. - let signer = hex64('a'); - let msg = describe_auth_failure(&[], &signer); - assert!( - msg.contains("no \"auth\" tag"), - "expected 'no auth tag' message, got: {msg}" + fn classify_empty_tags_returns_no_auth_tag() { + assert_eq!( + classify_owner_auth_tag(&[], &hex64('a')), + Err(AuthFailure::NoAuthTag) ); } #[test] - fn auth_failure_duplicate_auth_tags_names_count() { - // Case 3 (>1 auth tags): ambiguous ownership. + fn classify_duplicate_auth_tags_returns_ambiguous() { let signer = hex64('a'); let sig = hex128('b'); let tags = vec![ json!(["auth", signer, "conditions", sig]), json!(["auth", signer, "conditions", sig]), ]; - let msg = describe_auth_failure(&tags, &signer); - assert!( - msg.contains('2') && msg.contains("ambiguous"), - "expected count + ambiguous message, got: {msg}" + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::AmbiguousAuthTag(2)) ); } #[test] - fn auth_failure_malformed_wrong_arity_names_element_count() { - // Case 4: sole auth tag has wrong arity (3 elements instead of 4). + fn classify_wrong_arity_returns_wrong_arity() { let signer = hex64('a'); let tags = vec![json!(["auth", signer, "conditions"])]; - let msg = describe_auth_failure(&tags, &signer); - assert!( - msg.contains("element") && msg.contains('3'), - "expected element-count message, got: {msg}" + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::WrongArity(3)) ); } #[test] - fn auth_failure_malformed_non_string_element() { - // Case 4: sole auth tag contains a non-string element (numeric conditions). + fn classify_non_string_element_returns_non_string() { let signer = hex64('a'); let tags = vec![json!(["auth", signer, 42, hex128('b')])]; - let msg = describe_auth_failure(&tags, &signer); - assert!( - msg.contains("non-string"), - "expected non-string message, got: {msg}" + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::NonStringElement) ); } #[test] - fn auth_failure_malformed_non_hex_owner_names_owner() { - // Case 4: owner field is not valid 64-hex. + fn classify_invalid_owner_hex_returns_invalid_owner_hex() { let bad_owner = "z".repeat(64); let tags = vec![json!(["auth", bad_owner, "", hex128('a')])]; - let msg = describe_auth_failure(&tags, &bad_owner); - assert!( - msg.contains("owner field") && msg.contains("not a valid 64-hex"), - "expected owner-hex message, got: {msg}" + assert_eq!( + classify_owner_auth_tag(&tags, &bad_owner), + Err(AuthFailure::InvalidOwnerHex(bad_owner)) ); } #[test] - fn auth_failure_malformed_non_hex_sig_names_sig() { - // Case 4: sig field is not valid 128-hex. + fn classify_invalid_sig_hex_returns_invalid_sig_hex() { let signer = hex64('a'); let bad_sig = "z".repeat(128); let tags = vec![json!(["auth", signer, "", bad_sig])]; - let msg = describe_auth_failure(&tags, &signer); - assert!( - msg.contains("sig field") && msg.contains("not a valid 128-hex"), - "expected sig-hex message, got: {msg}" + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::InvalidSigHex) ); } #[test] - fn auth_failure_owner_mismatch_names_actual_owner_and_signer() { - // Case 4: structurally valid tag but owner pubkey ≠ signer — the - // diagnostic must print the actual owner so the user can act on it. + fn classify_owner_mismatch_returns_owner_mismatch_with_actual_owner() { + // Case 4: structurally valid tag but owner ≠ signer. The failure must + // carry the actual owner so resolve_auth can print it in the warning. let actual_owner = hex64('a'); let signer = hex64('b'); let sig = hex128('c'); let tags = vec![json!(["auth", actual_owner, "conditions", sig])]; - let msg = describe_auth_failure(&tags, &signer); + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::OwnerMismatch(actual_owner.clone())) + ); + // Message must include the actual owner for actionability. + let msg = AuthFailure::OwnerMismatch(actual_owner.clone()).message(); assert!( - msg.contains(&actual_owner) && msg.contains(&signer), - "expected both owner and signer pubkeys in message, got: {msg}" + msg.contains(&actual_owner), + "OwnerMismatch message must include actual owner, got: {msg}" ); + } + + // --- (c2) resolve_auth_from_profile emission boundary --- + // + // Observable-boundary tests: each test calls the production function + // `resolve_auth_from_profile` directly with a `Vec` sink and asserts + // on exactly what the production code writes. Deleting any `writeln!` + // call in that function makes at least one of these tests fail. + // + // `resolve_auth` is async and requires a live `BuzzClient`; the sync + // decomposition lets us test the warning logic without a relay connection. + + fn assert_one_json_warning(sink: &[u8], expected_fragment: &str) { + let text = std::str::from_utf8(sink).expect("sink is valid UTF-8"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!( + lines.len(), + 1, + "expected exactly one warning line, got: {text:?}" + ); + let parsed: serde_json::Value = + serde_json::from_str(lines[0]).expect("warning line must be parseable JSON"); + let warning = parsed["warning"] + .as_str() + .expect("warning line must have a string 'warning' field"); assert!( - msg.contains("does not match"), - "expected mismatch language, got: {msg}" + warning.contains(expected_fragment), + "warning must contain {expected_fragment:?}, got: {warning}" ); } + fn assert_no_warning(sink: &[u8]) { + let text = std::str::from_utf8(sink).expect("sink is valid UTF-8"); + assert!(text.is_empty(), "expected no warning output, got: {text:?}"); + } + + // Branch 1: profile == None → no kind:0 found. + #[test] + fn resolve_auth_from_profile_no_kind0_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(None, &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no kind:0 profile found"); + } + + // Branch 2: profile present but no tags array. + #[test] + fn resolve_auth_from_profile_no_tags_array_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let profile = json!({"kind": 0, "content": "{}"}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no tags array"); + } + + // Branch 3a: tags present but no auth tag (NoAuthTag). + #[test] + fn resolve_auth_from_profile_no_auth_tag_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let profile = json!({"tags": [["p", hex64('b')]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no \"auth\" tag"); + } + + // Branch 3b: duplicate auth tags (AmbiguousAuthTag). + #[test] + fn resolve_auth_from_profile_ambiguous_auth_tag_emits_json_warning() { + let signer = hex64('s'); + let sig = hex128('b'); + let profile = json!({"tags": [ + ["auth", signer, "conditions", sig], + ["auth", signer, "conditions", sig], + ]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "ambiguous"); + } + + // Branch 3c: sole auth tag malformed (WrongArity). + #[test] + fn resolve_auth_from_profile_malformed_tag_emits_json_warning() { + let signer = hex64('s'); + // arity 3 — missing sig field + let profile = json!({"tags": [["auth", signer, "conditions"]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "element"); + } + + // Branch 3d: owner mismatch — warning must include the actual owner pubkey. + #[test] + fn resolve_auth_from_profile_owner_mismatch_emits_json_warning_with_actual_owner() { + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, &actual_owner); + } + + // Success path: valid auth tag → Some returned, sink stays empty. + #[test] + fn resolve_auth_from_profile_valid_auth_tag_returns_some_emits_nothing() { + let signer = hex64('a'); + let sig = hex128('b'); + let profile = json!({"tags": [["auth", signer, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_some(), "must return the extracted tag"); + assert_no_warning(&sink); + } + + // Warning output must be valid JSON (serde_json serializes safely). + #[test] + fn resolve_auth_from_profile_warning_is_valid_json() { + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let _ = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + let text = std::str::from_utf8(&sink).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(text.trim()).expect("warning output must be valid JSON"); + assert!(parsed["warning"].is_string()); + } + // --- (d) NIP-11 self normalization: normalize_relay_self_hex --- #[test]