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> {
120 let (nick, domain) = s
121 .split_once('@')
122 .ok_or_else(|| anyhow!("handle missing '@' separator: {s:?}"))?;
123 if nick.is_empty() || domain.is_empty() {
124 bail!("handle has empty nick or domain: {s:?}");
125 }
126 if !nick_syntax_ok(nick) {
132 bail!(
133 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-]"
134 );
135 }
136 if !is_valid_domain(domain) {
137 bail!(
138 "domain {domain:?} invalid — expected a dot-separated lowercase-ASCII domain (e.g. wireup.net) or a loopback authority (127.0.0.1:PORT / localhost:PORT)"
139 );
140 }
141 Ok(Handle {
142 nick: nick.to_string(),
143 domain: domain.to_string(),
144 })
145}
146
147pub fn nick_syntax_ok(s: &str) -> bool {
151 let len = s.len();
152 if !(2..=32).contains(&len) {
153 return false;
154 }
155 s.bytes()
156 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
157}
158
159pub fn is_valid_nick(s: &str) -> bool {
164 nick_syntax_ok(s) && !RESERVED_NICKS.contains(&s)
165}
166
167fn is_valid_domain(s: &str) -> bool {
168 if s.is_empty() || s.len() > 253 {
169 return false;
170 }
171 if let Some((host, port)) = s.rsplit_once(':') {
177 return crate::endpoints::is_loopback_host(host)
178 && matches!(port.parse::<u16>(), Ok(p) if p >= 1);
179 }
180 s.split('.').all(|label| {
182 !label.is_empty()
183 && label.len() <= 63
184 && label
185 .bytes()
186 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
187 && !label.starts_with('-')
188 && !label.ends_with('-')
189 })
190}
191
192pub fn relay_url_for_domain(domain: &str) -> String {
198 let host = domain.rsplit_once(':').map(|(h, _)| h).unwrap_or(domain);
199 if crate::endpoints::is_loopback_host(host) {
200 format!("http://{domain}")
201 } else {
202 format!("https://{domain}")
203 }
204}
205
206pub const PROFILE_FIELDS: &[&str] = &[
209 "display_name",
210 "emoji",
211 "motto",
212 "vibe",
213 "pronouns",
214 "avatar_url",
215 "handle",
216 "now",
217 "listed",
218 "role",
219];
220
221pub fn read_profile() -> Result<Value> {
224 let card = config::read_agent_card()?;
225 Ok(card.get("profile").cloned().unwrap_or_else(|| json!({})))
226}
227
228pub fn write_profile_field(field: &str, value: Value) -> Result<Value> {
232 if !PROFILE_FIELDS.contains(&field) {
233 bail!(
234 "unknown profile field {field:?}; allowed: {}",
235 PROFILE_FIELDS.join(", ")
236 );
237 }
238 if field == "handle" {
240 let s = value
241 .as_str()
242 .ok_or_else(|| anyhow!("handle must be a string"))?;
243 parse_handle(s)?;
244 }
245 if field == "vibe" && !value.is_array() {
246 bail!("vibe must be a JSON array of strings");
247 }
248 if field == "now" && !(value.is_null() || value.is_object()) {
249 bail!("now must be a JSON object with text/since/ttl_secs or null");
250 }
251
252 let mut card = config::read_agent_card()?;
253 let card_obj = card
254 .as_object_mut()
255 .ok_or_else(|| anyhow!("agent-card is not a JSON object"))?;
256
257 let profile = card_obj
259 .entry("profile".to_string())
260 .or_insert_with(|| json!({"schema_version": PROFILE_SCHEMA_VERSION}));
261 let profile_obj = profile
262 .as_object_mut()
263 .ok_or_else(|| anyhow!("profile field is not an object"))?;
264
265 if value.is_null() {
266 profile_obj.remove(field);
267 } else {
268 profile_obj.insert(field.to_string(), value);
269 }
270 profile_obj.insert("schema_version".to_string(), json!(PROFILE_SCHEMA_VERSION));
271
272 let sk_seed = config::read_private_key()?;
274 card_obj.remove("signature");
276 let resigned = crate::agent_card::sign_agent_card(&card, &sk_seed);
277 config::write_agent_card(&resigned)?;
278
279 Ok(resigned.get("profile").cloned().unwrap_or(Value::Null))
280}
281
282pub fn resolve_handle(handle: &Handle, relay_url: Option<&str>) -> anyhow::Result<Value> {
291 let base = relay_url
292 .map(str::to_string)
293 .unwrap_or_else(|| relay_url_for_domain(&handle.domain));
294 let client = crate::relay_client::RelayClient::new(&base);
295
296 match client.well_known_agent(&handle.nick) {
302 Ok(resolved) => verify_wire_native_payload(&resolved).map(|()| resolved),
303 Err(_wire_err) => {
304 let a2a_card = client.well_known_agent_card_a2a(&handle.nick)?;
306 unwrap_a2a_to_wire_payload(&a2a_card)
307 }
308 }
309}
310
311fn verify_wire_native_payload(resolved: &Value) -> anyhow::Result<()> {
314 let card = resolved
315 .get("card")
316 .ok_or_else(|| anyhow!("resolved payload missing 'card' field"))?;
317 crate::agent_card::verify_agent_card(card)
318 .map_err(|e| anyhow!("resolved card signature invalid: {e}"))?;
319 let did_in_resp = resolved
320 .get("did")
321 .and_then(Value::as_str)
322 .ok_or_else(|| anyhow!("resolved payload missing 'did'"))?;
323 let did_in_card = card
324 .get("did")
325 .and_then(Value::as_str)
326 .ok_or_else(|| anyhow!("resolved card missing 'did'"))?;
327 if did_in_resp != did_in_card {
328 bail!("resolved DID mismatch: payload={did_in_resp} card={did_in_card}");
329 }
330 Ok(())
331}
332
333fn unwrap_a2a_to_wire_payload(a2a: &Value) -> anyhow::Result<Value> {
338 let wire_ext = a2a
339 .get("extensions")
340 .and_then(Value::as_array)
341 .and_then(|exts| {
342 exts.iter().find(|e| {
343 e.get("uri")
344 .and_then(Value::as_str)
345 .map(|u| u.starts_with("https://slancha.ai/wire/ext"))
346 .unwrap_or(false)
347 })
348 });
349 if let Some(ext) = wire_ext {
350 let params = ext
351 .get("params")
352 .cloned()
353 .ok_or_else(|| anyhow!("A2A wire extension missing params"))?;
354 if let Some(card) = params.get("card") {
356 crate::agent_card::verify_agent_card(card)
357 .map_err(|e| anyhow!("A2A wire extension card sig invalid: {e}"))?;
358 }
359 return Ok(params);
360 }
361
362 Ok(json!({
366 "did": a2a.get("id").cloned().unwrap_or(Value::Null),
367 "nick": a2a.get("name").cloned().unwrap_or(Value::Null),
368 "card": Value::Null,
369 "slot_id": Value::Null,
370 "relay_url": a2a.get("endpoint").cloned().unwrap_or(Value::Null),
371 "claimed_at": Value::Null,
372 "a2a_only": true,
373 "a2a_card": a2a.clone(),
374 }))
375}
376
377pub fn render_self_summary() -> Result<String> {
380 let card = config::read_agent_card()?;
381 let did = card
382 .get("did")
383 .and_then(Value::as_str)
384 .unwrap_or("did:wire:?")
385 .to_string();
386 let local_handle = crate::agent_card::display_handle_from_did(&did).to_string();
387 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
388
389 let mut out = String::new();
390 let line = |out: &mut String, k: &str, v: &str| {
391 if !v.is_empty() {
392 out.push_str(&format!(" {k:14}{v}\n"));
393 }
394 };
395
396 out.push_str(&format!("{did}\n"));
397
398 if let Some(handle) = profile.get("handle").and_then(Value::as_str) {
399 line(&mut out, "handle:", handle);
400 } else {
401 line(&mut out, "handle:", &format!("{local_handle}@(unset)"));
402 }
403 if let Some(name) = profile.get("display_name").and_then(Value::as_str) {
404 line(&mut out, "display_name:", name);
405 }
406 if let Some(emoji) = profile.get("emoji").and_then(Value::as_str) {
407 line(&mut out, "emoji:", emoji);
408 }
409 if let Some(motto) = profile.get("motto").and_then(Value::as_str) {
410 line(&mut out, "motto:", motto);
411 }
412 if let Some(vibe) = profile.get("vibe").and_then(Value::as_array) {
413 let joined: Vec<String> = vibe
414 .iter()
415 .filter_map(|v| v.as_str().map(str::to_string))
416 .collect();
417 line(&mut out, "vibe:", &joined.join(", "));
418 }
419 if let Some(pronouns) = profile.get("pronouns").and_then(Value::as_str) {
420 line(&mut out, "pronouns:", pronouns);
421 }
422 if let Some(now) = profile.get("now")
423 && let Some(text) = now.get("text").and_then(Value::as_str)
424 {
425 line(&mut out, "now:", text);
426 }
427 Ok(out)
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn parse_handle_round_trip() {
436 let h = parse_handle("coffee-ghost@anthropic.dev").unwrap();
437 assert_eq!(h.nick, "coffee-ghost");
438 assert_eq!(h.domain, "anthropic.dev");
439 assert_eq!(h.as_string(), "coffee-ghost@anthropic.dev");
440 }
441
442 #[test]
443 fn parse_handle_accepts_underscore_and_digits() {
444 assert!(parse_handle("dragonfly_42@home.arpa").is_ok());
445 assert!(parse_handle("v2@wireup.net").is_ok());
446 }
447
448 #[test]
449 fn parse_handle_accepts_loopback_with_port() {
450 for h in [
452 "bob@127.0.0.1:8771",
453 "bob@localhost:8771",
454 "bob@127.0.0.1:65535",
455 "bob@127.0.0.1:1",
456 ] {
457 assert!(parse_handle(h).is_ok(), "expected {h:?} to parse");
458 }
459 assert!(parse_handle("bob@127.0.0.1").is_ok());
461 assert_eq!(
463 parse_handle("bob@127.0.0.1:8771").unwrap().as_string(),
464 "bob@127.0.0.1:8771"
465 );
466 }
467
468 #[test]
469 fn parse_handle_rejects_nonloopback_port_and_bad_ports() {
470 assert!(parse_handle("bob@evil.com:1337").is_err());
472 assert!(parse_handle("bob@wireup.net:8443").is_err());
473 assert!(parse_handle("bob@127.0.0.1:0").is_err());
475 assert!(parse_handle("bob@127.0.0.1:65536").is_err());
476 assert!(parse_handle("bob@127.0.0.1:abc").is_err());
477 assert!(parse_handle("bob@:8771").is_err()); assert!(parse_handle("bob@::1:8771").is_err());
481 assert!(parse_handle("bob@wireup.net").is_ok());
483 }
484
485 #[test]
486 fn relay_url_for_domain_scheme() {
487 assert_eq!(
489 relay_url_for_domain("127.0.0.1:8771"),
490 "http://127.0.0.1:8771"
491 );
492 assert_eq!(relay_url_for_domain("localhost:9"), "http://localhost:9");
493 assert_eq!(relay_url_for_domain("127.0.0.1"), "http://127.0.0.1");
494 assert_eq!(relay_url_for_domain("wireup.net"), "https://wireup.net");
496 assert_eq!(
497 relay_url_for_domain("anthropic.dev"),
498 "https://anthropic.dev"
499 );
500 }
501
502 #[test]
503 fn parse_handle_rejects_no_at() {
504 assert!(parse_handle("paul").is_err());
505 assert!(parse_handle("paul.example.com").is_err());
506 }
507
508 #[test]
509 fn parse_handle_rejects_empty_parts() {
510 assert!(parse_handle("@example.com").is_err());
511 assert!(parse_handle("paul@").is_err());
512 }
513
514 #[test]
515 fn parse_handle_accepts_reserved_nicks_for_resolution() {
516 for r in RESERVED_NICKS {
521 if r.len() < 2 {
523 continue;
524 }
525 let s = format!("{r}@example.com");
526 assert!(
527 parse_handle(&s).is_ok(),
528 "expected reserved nick {r:?} to parse OK for resolution"
529 );
530 }
531 }
532
533 #[test]
534 fn is_valid_nick_rejects_reserved() {
535 for r in RESERVED_NICKS {
536 assert!(
537 !is_valid_nick(r),
538 "expected is_valid_nick to reject reserved nick {r:?} (claim-time check)"
539 );
540 }
541 }
542
543 #[test]
544 fn parse_handle_rejects_single_char_nick() {
545 assert!(parse_handle("a@example.com").is_err());
546 }
547
548 #[test]
549 fn parse_handle_rejects_uppercase_or_emoji_in_nick() {
550 assert!(parse_handle("Paul@example.com").is_err());
551 assert!(parse_handle("p👻@example.com").is_err());
552 }
553
554 #[test]
555 fn parse_handle_rejects_overlong_nick() {
556 let long = "a".repeat(33);
557 let s = format!("{long}@example.com");
558 assert!(parse_handle(&s).is_err());
559 }
560
561 #[test]
562 fn parse_handle_rejects_bad_domain() {
563 assert!(parse_handle("paul@-bad.example.com").is_err());
564 assert!(parse_handle("paul@bad-.example.com").is_err());
565 assert!(parse_handle("paul@.bad.com").is_err());
566 }
567
568 #[test]
569 fn is_valid_nick_lower_bound() {
570 assert!(!is_valid_nick("a"));
571 assert!(is_valid_nick("ab"));
572 }
573}