1use anyhow::{Result, anyhow, bail};
16use serde_json::{Value, json};
17
18use crate::config;
19
20pub const PROFILE_SCHEMA_VERSION: &str = "v0.5";
21
22pub const RESERVED_NICKS: &[&str] = &[
39 "abuse",
40 "admin",
41 "agent",
42 "all",
43 "anthropic",
44 "api",
45 "bar",
46 "baz",
47 "bot",
48 "claude",
49 "contact",
50 "copilot",
51 "cursor",
52 "daemon",
53 "demo",
54 "everyone",
55 "example",
56 "foo",
57 "gemini",
58 "help",
59 "here",
60 "hostmaster",
61 "info",
62 "kernel",
63 "me",
64 "mistral",
65 "mod",
66 "moderator",
67 "none",
68 "noreply",
69 "null",
70 "official",
71 "openai",
72 "ops",
73 "owner",
74 "postmaster",
75 "robot",
76 "root",
77 "security",
78 "self",
79 "server",
80 "service",
81 "slancha",
82 "staff",
83 "support",
84 "sys",
85 "system",
86 "team",
87 "test",
88 "webmaster",
89 "wire",
90 "you",
91];
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Handle {
96 pub nick: String,
97 pub domain: String,
98}
99
100impl Handle {
101 pub fn as_string(&self) -> String {
102 format!("{}@{}", self.nick, self.domain)
103 }
104}
105
106impl std::fmt::Display for Handle {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 write!(f, "{}@{}", self.nick, self.domain)
109 }
110}
111
112pub fn parse_handle(s: &str) -> Result<Handle> {
118 let (nick, domain) = s
119 .split_once('@')
120 .ok_or_else(|| anyhow!("handle missing '@' separator: {s:?}"))?;
121 if nick.is_empty() || domain.is_empty() {
122 bail!("handle has empty nick or domain: {s:?}");
123 }
124 if !nick_syntax_ok(nick) {
130 bail!(
131 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-]"
132 );
133 }
134 if !is_valid_domain(domain) {
135 bail!("domain {domain:?} invalid — must be lowercase ASCII, dot-separated");
136 }
137 Ok(Handle {
138 nick: nick.to_string(),
139 domain: domain.to_string(),
140 })
141}
142
143pub fn nick_syntax_ok(s: &str) -> bool {
147 let len = s.len();
148 if !(2..=32).contains(&len) {
149 return false;
150 }
151 s.bytes()
152 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
153}
154
155pub fn is_valid_nick(s: &str) -> bool {
160 nick_syntax_ok(s) && !RESERVED_NICKS.contains(&s)
161}
162
163fn is_valid_domain(s: &str) -> bool {
164 if s.is_empty() || s.len() > 253 {
165 return false;
166 }
167 s.split('.').all(|label| {
169 !label.is_empty()
170 && label.len() <= 63
171 && label
172 .bytes()
173 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
174 && !label.starts_with('-')
175 && !label.ends_with('-')
176 })
177}
178
179pub const PROFILE_FIELDS: &[&str] = &[
182 "display_name",
183 "emoji",
184 "motto",
185 "vibe",
186 "pronouns",
187 "avatar_url",
188 "handle",
189 "now",
190 "listed",
191];
192
193pub fn read_profile() -> Result<Value> {
196 let card = config::read_agent_card()?;
197 Ok(card.get("profile").cloned().unwrap_or(Value::Null))
198}
199
200pub fn write_profile_field(field: &str, value: Value) -> Result<Value> {
204 if !PROFILE_FIELDS.contains(&field) {
205 bail!(
206 "unknown profile field {field:?}; allowed: {}",
207 PROFILE_FIELDS.join(", ")
208 );
209 }
210 if field == "handle" {
212 let s = value
213 .as_str()
214 .ok_or_else(|| anyhow!("handle must be a string"))?;
215 parse_handle(s)?;
216 }
217 if field == "vibe" && !value.is_array() {
218 bail!("vibe must be a JSON array of strings");
219 }
220 if field == "now" && !(value.is_null() || value.is_object()) {
221 bail!("now must be a JSON object with text/since/ttl_secs or null");
222 }
223
224 let mut card = config::read_agent_card()?;
225 let card_obj = card
226 .as_object_mut()
227 .ok_or_else(|| anyhow!("agent-card is not a JSON object"))?;
228
229 let profile = card_obj
231 .entry("profile".to_string())
232 .or_insert_with(|| json!({"schema_version": PROFILE_SCHEMA_VERSION}));
233 let profile_obj = profile
234 .as_object_mut()
235 .ok_or_else(|| anyhow!("profile field is not an object"))?;
236
237 if value.is_null() {
238 profile_obj.remove(field);
239 } else {
240 profile_obj.insert(field.to_string(), value);
241 }
242 profile_obj.insert("schema_version".to_string(), json!(PROFILE_SCHEMA_VERSION));
243
244 let sk_seed = config::read_private_key()?;
246 card_obj.remove("signature");
248 let resigned = crate::agent_card::sign_agent_card(&card, &sk_seed);
249 config::write_agent_card(&resigned)?;
250
251 Ok(resigned.get("profile").cloned().unwrap_or(Value::Null))
252}
253
254pub fn resolve_handle(handle: &Handle, relay_url: Option<&str>) -> anyhow::Result<Value> {
263 let base = relay_url
264 .map(str::to_string)
265 .unwrap_or_else(|| format!("https://{}", handle.domain));
266 let client = crate::relay_client::RelayClient::new(&base);
267
268 match client.well_known_agent(&handle.nick) {
274 Ok(resolved) => verify_wire_native_payload(&resolved).map(|()| resolved),
275 Err(_wire_err) => {
276 let a2a_card = client.well_known_agent_card_a2a(&handle.nick)?;
278 unwrap_a2a_to_wire_payload(&a2a_card)
279 }
280 }
281}
282
283fn verify_wire_native_payload(resolved: &Value) -> anyhow::Result<()> {
286 let card = resolved
287 .get("card")
288 .ok_or_else(|| anyhow!("resolved payload missing 'card' field"))?;
289 crate::agent_card::verify_agent_card(card)
290 .map_err(|e| anyhow!("resolved card signature invalid: {e}"))?;
291 let did_in_resp = resolved
292 .get("did")
293 .and_then(Value::as_str)
294 .ok_or_else(|| anyhow!("resolved payload missing 'did'"))?;
295 let did_in_card = card
296 .get("did")
297 .and_then(Value::as_str)
298 .ok_or_else(|| anyhow!("resolved card missing 'did'"))?;
299 if did_in_resp != did_in_card {
300 bail!("resolved DID mismatch: payload={did_in_resp} card={did_in_card}");
301 }
302 Ok(())
303}
304
305fn unwrap_a2a_to_wire_payload(a2a: &Value) -> anyhow::Result<Value> {
310 let wire_ext = a2a
311 .get("extensions")
312 .and_then(Value::as_array)
313 .and_then(|exts| {
314 exts.iter().find(|e| {
315 e.get("uri")
316 .and_then(Value::as_str)
317 .map(|u| u.starts_with("https://slancha.ai/wire/ext"))
318 .unwrap_or(false)
319 })
320 });
321 if let Some(ext) = wire_ext {
322 let params = ext
323 .get("params")
324 .cloned()
325 .ok_or_else(|| anyhow!("A2A wire extension missing params"))?;
326 if let Some(card) = params.get("card") {
328 crate::agent_card::verify_agent_card(card)
329 .map_err(|e| anyhow!("A2A wire extension card sig invalid: {e}"))?;
330 }
331 return Ok(params);
332 }
333
334 Ok(json!({
338 "did": a2a.get("id").cloned().unwrap_or(Value::Null),
339 "nick": a2a.get("name").cloned().unwrap_or(Value::Null),
340 "card": Value::Null,
341 "slot_id": Value::Null,
342 "relay_url": a2a.get("endpoint").cloned().unwrap_or(Value::Null),
343 "claimed_at": Value::Null,
344 "a2a_only": true,
345 "a2a_card": a2a.clone(),
346 }))
347}
348
349pub fn render_self_summary() -> Result<String> {
352 let card = config::read_agent_card()?;
353 let did = card
354 .get("did")
355 .and_then(Value::as_str)
356 .unwrap_or("did:wire:?")
357 .to_string();
358 let local_handle = crate::agent_card::display_handle_from_did(&did).to_string();
359 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
360
361 let mut out = String::new();
362 let line = |out: &mut String, k: &str, v: &str| {
363 if !v.is_empty() {
364 out.push_str(&format!(" {k:14}{v}\n"));
365 }
366 };
367
368 out.push_str(&format!("{}\n", did));
369
370 if let Some(handle) = profile.get("handle").and_then(Value::as_str) {
371 line(&mut out, "handle:", handle);
372 } else {
373 line(&mut out, "handle:", &format!("{local_handle}@(unset)"));
374 }
375 if let Some(name) = profile.get("display_name").and_then(Value::as_str) {
376 line(&mut out, "display_name:", name);
377 }
378 if let Some(emoji) = profile.get("emoji").and_then(Value::as_str) {
379 line(&mut out, "emoji:", emoji);
380 }
381 if let Some(motto) = profile.get("motto").and_then(Value::as_str) {
382 line(&mut out, "motto:", motto);
383 }
384 if let Some(vibe) = profile.get("vibe").and_then(Value::as_array) {
385 let joined: Vec<String> = vibe
386 .iter()
387 .filter_map(|v| v.as_str().map(str::to_string))
388 .collect();
389 line(&mut out, "vibe:", &joined.join(", "));
390 }
391 if let Some(pronouns) = profile.get("pronouns").and_then(Value::as_str) {
392 line(&mut out, "pronouns:", pronouns);
393 }
394 if let Some(now) = profile.get("now")
395 && let Some(text) = now.get("text").and_then(Value::as_str)
396 {
397 line(&mut out, "now:", text);
398 }
399 Ok(out)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn parse_handle_round_trip() {
408 let h = parse_handle("coffee-ghost@anthropic.dev").unwrap();
409 assert_eq!(h.nick, "coffee-ghost");
410 assert_eq!(h.domain, "anthropic.dev");
411 assert_eq!(h.as_string(), "coffee-ghost@anthropic.dev");
412 }
413
414 #[test]
415 fn parse_handle_accepts_underscore_and_digits() {
416 assert!(parse_handle("dragonfly_42@home.arpa").is_ok());
417 assert!(parse_handle("v2@wireup.net").is_ok());
418 }
419
420 #[test]
421 fn parse_handle_rejects_no_at() {
422 assert!(parse_handle("paul").is_err());
423 assert!(parse_handle("paul.example.com").is_err());
424 }
425
426 #[test]
427 fn parse_handle_rejects_empty_parts() {
428 assert!(parse_handle("@example.com").is_err());
429 assert!(parse_handle("paul@").is_err());
430 }
431
432 #[test]
433 fn parse_handle_accepts_reserved_nicks_for_resolution() {
434 for r in RESERVED_NICKS {
439 if r.len() < 2 {
441 continue;
442 }
443 let s = format!("{r}@example.com");
444 assert!(
445 parse_handle(&s).is_ok(),
446 "expected reserved nick {r:?} to parse OK for resolution"
447 );
448 }
449 }
450
451 #[test]
452 fn is_valid_nick_rejects_reserved() {
453 for r in RESERVED_NICKS {
454 assert!(
455 !is_valid_nick(r),
456 "expected is_valid_nick to reject reserved nick {r:?} (claim-time check)"
457 );
458 }
459 }
460
461 #[test]
462 fn parse_handle_rejects_single_char_nick() {
463 assert!(parse_handle("a@example.com").is_err());
464 }
465
466 #[test]
467 fn parse_handle_rejects_uppercase_or_emoji_in_nick() {
468 assert!(parse_handle("Paul@example.com").is_err());
469 assert!(parse_handle("p👻@example.com").is_err());
470 }
471
472 #[test]
473 fn parse_handle_rejects_overlong_nick() {
474 let long = "a".repeat(33);
475 let s = format!("{long}@example.com");
476 assert!(parse_handle(&s).is_err());
477 }
478
479 #[test]
480 fn parse_handle_rejects_bad_domain() {
481 assert!(parse_handle("paul@-bad.example.com").is_err());
482 assert!(parse_handle("paul@bad-.example.com").is_err());
483 assert!(parse_handle("paul@.bad.com").is_err());
484 }
485
486 #[test]
487 fn is_valid_nick_lower_bound() {
488 assert!(!is_valid_nick("a"));
489 assert!(is_valid_nick("ab"));
490 }
491}