Skip to content

Commit 120db5e

Browse files
committed
feat(cli): add agents archive/unarchive/archived subcommands
Implements the NIP-IA identity archive CLI surface per the Rev 3 plan: archive (kind:9035), unarchive (kind:9036), and archived (kind:13535 snapshot query). Includes non-ambient signing seam (sign_event_unchecked), NIP-OA auth-tag resolution with structural validation, NIP-11 relay info fetch with Accept: application/nostr+json, and fail-closed tri-state verification of the archived-identities snapshot.
1 parent 8a65d81 commit 120db5e

4 files changed

Lines changed: 1063 additions & 52 deletions

File tree

crates/buzz-cli/src/client.rs

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,39 @@ impl BuzzClient {
428428
}
429429
}
430430

431+
/// Sign an event builder verbatim: no NIP-OA auth-tag injection, and none
432+
/// of [`sign_event`]'s "callers must not add auth tags" enforcement.
433+
///
434+
/// Used only for NIP-IA identity archive/unarchive requests (kind
435+
/// 9035/9036), whose optional `auth` tag is a *content-level*
436+
/// owner-of-agent attestation about the *target* identity — unrelated to
437+
/// this client's own NIP-OA membership delegation (`self.auth_tag`,
438+
/// which [`sign_event`] injects into every other event and which
439+
/// `submit_event` separately attaches via the `x-auth-tag` HTTP header).
440+
/// Routing an identity archive request through `sign_event` would either
441+
/// silently drop the caller's owner attestation or double up an
442+
/// unrelated tag.
443+
pub fn sign_event_unchecked(&self, builder: EventBuilder) -> Result<nostr::Event, CliError> {
444+
builder
445+
.sign_with_keys(&self.keys)
446+
.map_err(|e| CliError::Other(format!("signing failed: {e}")))
447+
}
448+
449+
/// GET a public, unauthenticated relay endpoint (e.g. the NIP-11 `/info`
450+
/// document), returning the raw JSON body. No NIP-98 Authorization and no
451+
/// `x-auth-tag` header — the endpoint is public relay metadata, not a
452+
/// membership-scoped resource.
453+
pub async fn get_public(&self, path: &str) -> Result<String, CliError> {
454+
let url = format!("{}{path}", self.relay_url);
455+
let resp = self
456+
.http
457+
.get(&url)
458+
.header("Accept", "application/nostr+json")
459+
.send()
460+
.await?;
461+
self.handle_response(resp).await
462+
}
463+
431464
/// Execute a one-shot query via the HTTP bridge.
432465
/// `filter` is a Nostr filter object (will be wrapped in an array).
433466
/// Returns the raw JSON response (array of events).
@@ -849,7 +882,8 @@ pub fn normalize_write_response(raw: &str) -> String {
849882

850883
#[cfg(test)]
851884
mod tests {
852-
use super::{create_response_with_id, extract_relay_response_field};
885+
use super::{create_response_with_id, extract_relay_response_field, BuzzClient};
886+
use nostr::{EventBuilder, Keys, Kind, Tag};
853887

854888
#[test]
855889
fn extract_relay_response_field_reads_response_message_json() {
@@ -875,4 +909,125 @@ mod tests {
875909
assert_eq!(v["event_id"].as_str(), Some("abc"));
876910
assert_eq!(v["accepted"].as_bool(), Some(true));
877911
}
912+
913+
// --- (a) auth-suppression regression pair ---
914+
915+
fn make_auth_tag() -> (Tag, String) {
916+
let owner_hex = "a".repeat(64);
917+
let sig_hex = "b".repeat(128);
918+
let tag_vec = vec![
919+
"auth".to_string(),
920+
owner_hex,
921+
"conditions".to_string(),
922+
sig_hex,
923+
];
924+
let json = serde_json::to_string(&tag_vec).unwrap();
925+
let tag = Tag::parse(tag_vec).unwrap();
926+
(tag, json)
927+
}
928+
929+
#[test]
930+
fn sign_event_unchecked_does_not_inject_ambient_auth_tag() {
931+
let keys = Keys::generate();
932+
let (auth_tag, auth_json) = make_auth_tag();
933+
let client = BuzzClient::new(
934+
"https://test.relay".into(),
935+
keys,
936+
Some(auth_tag),
937+
Some(auth_json),
938+
)
939+
.unwrap();
940+
941+
let builder =
942+
EventBuilder::new(Kind::Custom(9035), "archive").tags([Tag::parse(["-"]).unwrap()]);
943+
let event = client.sign_event_unchecked(builder).unwrap();
944+
945+
let auth_tags: Vec<_> = event
946+
.tags
947+
.iter()
948+
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
949+
.collect();
950+
assert!(
951+
auth_tags.is_empty(),
952+
"sign_event_unchecked must not inject the ambient NIP-OA auth tag \
953+
into identity archive events; found {auth_tags:?}"
954+
);
955+
}
956+
957+
#[test]
958+
fn sign_event_unchecked_preserves_callers_content_auth_tag() {
959+
let keys = Keys::generate();
960+
let (auth_tag, auth_json) = make_auth_tag();
961+
let client = BuzzClient::new(
962+
"https://test.relay".into(),
963+
keys,
964+
Some(auth_tag),
965+
Some(auth_json),
966+
)
967+
.unwrap();
968+
969+
let content_auth = Tag::parse([
970+
"auth",
971+
&"c".repeat(64),
972+
"owner-attestation",
973+
&"d".repeat(128),
974+
])
975+
.unwrap();
976+
977+
let builder = EventBuilder::new(Kind::Custom(9035), "archive")
978+
.tags([Tag::parse(["-"]).unwrap(), content_auth]);
979+
let event = client.sign_event_unchecked(builder).unwrap();
980+
981+
let auth_tags: Vec<_> = event
982+
.tags
983+
.iter()
984+
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
985+
.collect();
986+
assert_eq!(
987+
auth_tags.len(),
988+
1,
989+
"content-level auth tag must survive sign_event_unchecked; found {auth_tags:?}"
990+
);
991+
assert_eq!(auth_tags[0].as_slice()[1], "c".repeat(64));
992+
}
993+
994+
#[test]
995+
fn with_auth_tag_sets_header_when_configured() {
996+
let keys = Keys::generate();
997+
let (auth_tag, auth_json) = make_auth_tag();
998+
let client = BuzzClient::new(
999+
"https://test.relay".into(),
1000+
keys,
1001+
Some(auth_tag),
1002+
Some(auth_json.clone()),
1003+
)
1004+
.unwrap();
1005+
1006+
let req = client.http.post("https://test.relay/events");
1007+
let req = client.with_auth_tag(req);
1008+
let built = req.build().unwrap();
1009+
let header = built
1010+
.headers()
1011+
.get("x-auth-tag")
1012+
.expect("x-auth-tag header must be present");
1013+
assert_eq!(
1014+
header.to_str().unwrap(),
1015+
&auth_json,
1016+
"x-auth-tag header must carry the raw auth tag JSON"
1017+
);
1018+
}
1019+
1020+
#[test]
1021+
fn with_auth_tag_omits_header_when_not_configured() {
1022+
let keys = Keys::generate();
1023+
let client = BuzzClient::new("https://test.relay".into(), keys, None, None).unwrap();
1024+
1025+
let req = client.http.post("https://test.relay/events");
1026+
let req = client.with_auth_tag(req);
1027+
let built = req.build().unwrap();
1028+
assert!(
1029+
built.headers().get("x-auth-tag").is_none(),
1030+
"x-auth-tag header must not be present when no auth tag is configured"
1031+
);
1032+
}
8781033
}

0 commit comments

Comments
 (0)