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 "role",
192];
193
194pub fn read_profile() -> Result<Value> {
197 let card = config::read_agent_card()?;
198 Ok(card.get("profile").cloned().unwrap_or(Value::Null))
199}
200
201pub fn write_profile_field(field: &str, value: Value) -> Result<Value> {
205 if !PROFILE_FIELDS.contains(&field) {
206 bail!(
207 "unknown profile field {field:?}; allowed: {}",
208 PROFILE_FIELDS.join(", ")
209 );
210 }
211 if field == "handle" {
213 let s = value
214 .as_str()
215 .ok_or_else(|| anyhow!("handle must be a string"))?;
216 parse_handle(s)?;
217 }
218 if field == "vibe" && !value.is_array() {
219 bail!("vibe must be a JSON array of strings");
220 }
221 if field == "now" && !(value.is_null() || value.is_object()) {
222 bail!("now must be a JSON object with text/since/ttl_secs or null");
223 }
224
225 let mut card = config::read_agent_card()?;
226 let card_obj = card
227 .as_object_mut()
228 .ok_or_else(|| anyhow!("agent-card is not a JSON object"))?;
229
230 let profile = card_obj
232 .entry("profile".to_string())
233 .or_insert_with(|| json!({"schema_version": PROFILE_SCHEMA_VERSION}));
234 let profile_obj = profile
235 .as_object_mut()
236 .ok_or_else(|| anyhow!("profile field is not an object"))?;
237
238 if value.is_null() {
239 profile_obj.remove(field);
240 } else {
241 profile_obj.insert(field.to_string(), value);
242 }
243 profile_obj.insert("schema_version".to_string(), json!(PROFILE_SCHEMA_VERSION));
244
245 let sk_seed = config::read_private_key()?;
247 card_obj.remove("signature");
249 let resigned = crate::agent_card::sign_agent_card(&card, &sk_seed);
250 config::write_agent_card(&resigned)?;
251
252 Ok(resigned.get("profile").cloned().unwrap_or(Value::Null))
253}
254
255pub fn resolve_handle(handle: &Handle, relay_url: Option<&str>) -> anyhow::Result<Value> {
264 let base = relay_url
265 .map(str::to_string)
266 .unwrap_or_else(|| format!("https://{}", handle.domain));
267 let client = crate::relay_client::RelayClient::new(&base);
268
269 match client.well_known_agent(&handle.nick) {
275 Ok(resolved) => verify_wire_native_payload(&resolved).map(|()| resolved),
276 Err(_wire_err) => {
277 let a2a_card = client.well_known_agent_card_a2a(&handle.nick)?;
279 unwrap_a2a_to_wire_payload(&a2a_card)
280 }
281 }
282}
283
284fn verify_wire_native_payload(resolved: &Value) -> anyhow::Result<()> {
287 let card = resolved
288 .get("card")
289 .ok_or_else(|| anyhow!("resolved payload missing 'card' field"))?;
290 crate::agent_card::verify_agent_card(card)
291 .map_err(|e| anyhow!("resolved card signature invalid: {e}"))?;
292 let did_in_resp = resolved
293 .get("did")
294 .and_then(Value::as_str)
295 .ok_or_else(|| anyhow!("resolved payload missing 'did'"))?;
296 let did_in_card = card
297 .get("did")
298 .and_then(Value::as_str)
299 .ok_or_else(|| anyhow!("resolved card missing 'did'"))?;
300 if did_in_resp != did_in_card {
301 bail!("resolved DID mismatch: payload={did_in_resp} card={did_in_card}");
302 }
303 Ok(())
304}
305
306fn unwrap_a2a_to_wire_payload(a2a: &Value) -> anyhow::Result<Value> {
311 let wire_ext = a2a
312 .get("extensions")
313 .and_then(Value::as_array)
314 .and_then(|exts| {
315 exts.iter().find(|e| {
316 e.get("uri")
317 .and_then(Value::as_str)
318 .map(|u| u.starts_with("https://slancha.ai/wire/ext"))
319 .unwrap_or(false)
320 })
321 });
322 if let Some(ext) = wire_ext {
323 let params = ext
324 .get("params")
325 .cloned()
326 .ok_or_else(|| anyhow!("A2A wire extension missing params"))?;
327 if let Some(card) = params.get("card") {
329 crate::agent_card::verify_agent_card(card)
330 .map_err(|e| anyhow!("A2A wire extension card sig invalid: {e}"))?;
331 }
332 return Ok(params);
333 }
334
335 Ok(json!({
339 "did": a2a.get("id").cloned().unwrap_or(Value::Null),
340 "nick": a2a.get("name").cloned().unwrap_or(Value::Null),
341 "card": Value::Null,
342 "slot_id": Value::Null,
343 "relay_url": a2a.get("endpoint").cloned().unwrap_or(Value::Null),
344 "claimed_at": Value::Null,
345 "a2a_only": true,
346 "a2a_card": a2a.clone(),
347 }))
348}
349
350pub fn render_self_summary() -> Result<String> {
353 let card = config::read_agent_card()?;
354 let did = card
355 .get("did")
356 .and_then(Value::as_str)
357 .unwrap_or("did:wire:?")
358 .to_string();
359 let local_handle = crate::agent_card::display_handle_from_did(&did).to_string();
360 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
361
362 let mut out = String::new();
363 let line = |out: &mut String, k: &str, v: &str| {
364 if !v.is_empty() {
365 out.push_str(&format!(" {k:14}{v}\n"));
366 }
367 };
368
369 out.push_str(&format!("{}\n", did));
370
371 if let Some(handle) = profile.get("handle").and_then(Value::as_str) {
372 line(&mut out, "handle:", handle);
373 } else {
374 line(&mut out, "handle:", &format!("{local_handle}@(unset)"));
375 }
376 if let Some(name) = profile.get("display_name").and_then(Value::as_str) {
377 line(&mut out, "display_name:", name);
378 }
379 if let Some(emoji) = profile.get("emoji").and_then(Value::as_str) {
380 line(&mut out, "emoji:", emoji);
381 }
382 if let Some(motto) = profile.get("motto").and_then(Value::as_str) {
383 line(&mut out, "motto:", motto);
384 }
385 if let Some(vibe) = profile.get("vibe").and_then(Value::as_array) {
386 let joined: Vec<String> = vibe
387 .iter()
388 .filter_map(|v| v.as_str().map(str::to_string))
389 .collect();
390 line(&mut out, "vibe:", &joined.join(", "));
391 }
392 if let Some(pronouns) = profile.get("pronouns").and_then(Value::as_str) {
393 line(&mut out, "pronouns:", pronouns);
394 }
395 if let Some(now) = profile.get("now")
396 && let Some(text) = now.get("text").and_then(Value::as_str)
397 {
398 line(&mut out, "now:", text);
399 }
400 Ok(out)
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn parse_handle_round_trip() {
409 let h = parse_handle("coffee-ghost@anthropic.dev").unwrap();
410 assert_eq!(h.nick, "coffee-ghost");
411 assert_eq!(h.domain, "anthropic.dev");
412 assert_eq!(h.as_string(), "coffee-ghost@anthropic.dev");
413 }
414
415 #[test]
416 fn parse_handle_accepts_underscore_and_digits() {
417 assert!(parse_handle("dragonfly_42@home.arpa").is_ok());
418 assert!(parse_handle("v2@wireup.net").is_ok());
419 }
420
421 #[test]
422 fn parse_handle_rejects_no_at() {
423 assert!(parse_handle("paul").is_err());
424 assert!(parse_handle("paul.example.com").is_err());
425 }
426
427 #[test]
428 fn parse_handle_rejects_empty_parts() {
429 assert!(parse_handle("@example.com").is_err());
430 assert!(parse_handle("paul@").is_err());
431 }
432
433 #[test]
434 fn parse_handle_accepts_reserved_nicks_for_resolution() {
435 for r in RESERVED_NICKS {
440 if r.len() < 2 {
442 continue;
443 }
444 let s = format!("{r}@example.com");
445 assert!(
446 parse_handle(&s).is_ok(),
447 "expected reserved nick {r:?} to parse OK for resolution"
448 );
449 }
450 }
451
452 #[test]
453 fn is_valid_nick_rejects_reserved() {
454 for r in RESERVED_NICKS {
455 assert!(
456 !is_valid_nick(r),
457 "expected is_valid_nick to reject reserved nick {r:?} (claim-time check)"
458 );
459 }
460 }
461
462 #[test]
463 fn parse_handle_rejects_single_char_nick() {
464 assert!(parse_handle("a@example.com").is_err());
465 }
466
467 #[test]
468 fn parse_handle_rejects_uppercase_or_emoji_in_nick() {
469 assert!(parse_handle("Paul@example.com").is_err());
470 assert!(parse_handle("p👻@example.com").is_err());
471 }
472
473 #[test]
474 fn parse_handle_rejects_overlong_nick() {
475 let long = "a".repeat(33);
476 let s = format!("{long}@example.com");
477 assert!(parse_handle(&s).is_err());
478 }
479
480 #[test]
481 fn parse_handle_rejects_bad_domain() {
482 assert!(parse_handle("paul@-bad.example.com").is_err());
483 assert!(parse_handle("paul@bad-.example.com").is_err());
484 assert!(parse_handle("paul@.bad.com").is_err());
485 }
486
487 #[test]
488 fn is_valid_nick_lower_bound() {
489 assert!(!is_valid_nick("a"));
490 assert!(is_valid_nick("ab"));
491 }
492}