use anyhow::{Result, anyhow, bail};
use serde_json::{Value, json};
use crate::config;
pub const PROFILE_SCHEMA_VERSION: &str = "v0.5";
pub const RESERVED_NICKS: &[&str] = &[
"abuse",
"admin",
"agent",
"all",
"anthropic",
"api",
"bar",
"baz",
"bot",
"claude",
"contact",
"copilot",
"cursor",
"daemon",
"demo",
"everyone",
"example",
"foo",
"gemini",
"help",
"here",
"hostmaster",
"info",
"kernel",
"me",
"mistral",
"mod",
"moderator",
"none",
"noreply",
"null",
"official",
"openai",
"ops",
"owner",
"postmaster",
"robot",
"root",
"security",
"self",
"server",
"service",
"slancha",
"staff",
"support",
"sys",
"system",
"team",
"test",
"webmaster",
"wire",
"you",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Handle {
pub nick: String,
pub domain: String,
}
impl Handle {
pub fn as_string(&self) -> String {
format!("{}@{}", self.nick, self.domain)
}
}
impl std::fmt::Display for Handle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}@{}", self.nick, self.domain)
}
}
pub fn parse_handle(s: &str) -> Result<Handle> {
let (nick, domain) = s
.split_once('@')
.ok_or_else(|| anyhow!("handle missing '@' separator: {s:?}"))?;
if nick.is_empty() || domain.is_empty() {
bail!("handle has empty nick or domain: {s:?}");
}
if !nick_syntax_ok(nick) {
bail!(
"phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-]"
);
}
if !is_valid_domain(domain) {
bail!("domain {domain:?} invalid — must be lowercase ASCII, dot-separated");
}
Ok(Handle {
nick: nick.to_string(),
domain: domain.to_string(),
})
}
pub fn nick_syntax_ok(s: &str) -> bool {
let len = s.len();
if !(2..=32).contains(&len) {
return false;
}
s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
}
pub fn is_valid_nick(s: &str) -> bool {
nick_syntax_ok(s) && !RESERVED_NICKS.contains(&s)
}
fn is_valid_domain(s: &str) -> bool {
if s.is_empty() || s.len() > 253 {
return false;
}
s.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !label.starts_with('-')
&& !label.ends_with('-')
})
}
pub const PROFILE_FIELDS: &[&str] = &[
"display_name",
"emoji",
"motto",
"vibe",
"pronouns",
"avatar_url",
"handle",
"now",
"listed",
];
pub fn read_profile() -> Result<Value> {
let card = config::read_agent_card()?;
Ok(card.get("profile").cloned().unwrap_or(Value::Null))
}
pub fn write_profile_field(field: &str, value: Value) -> Result<Value> {
if !PROFILE_FIELDS.contains(&field) {
bail!(
"unknown profile field {field:?}; allowed: {}",
PROFILE_FIELDS.join(", ")
);
}
if field == "handle" {
let s = value
.as_str()
.ok_or_else(|| anyhow!("handle must be a string"))?;
parse_handle(s)?;
}
if field == "vibe" && !value.is_array() {
bail!("vibe must be a JSON array of strings");
}
if field == "now" && !(value.is_null() || value.is_object()) {
bail!("now must be a JSON object with text/since/ttl_secs or null");
}
let mut card = config::read_agent_card()?;
let card_obj = card
.as_object_mut()
.ok_or_else(|| anyhow!("agent-card is not a JSON object"))?;
let profile = card_obj
.entry("profile".to_string())
.or_insert_with(|| json!({"schema_version": PROFILE_SCHEMA_VERSION}));
let profile_obj = profile
.as_object_mut()
.ok_or_else(|| anyhow!("profile field is not an object"))?;
if value.is_null() {
profile_obj.remove(field);
} else {
profile_obj.insert(field.to_string(), value);
}
profile_obj.insert("schema_version".to_string(), json!(PROFILE_SCHEMA_VERSION));
let sk_seed = config::read_private_key()?;
card_obj.remove("signature");
let resigned = crate::agent_card::sign_agent_card(&card, &sk_seed);
config::write_agent_card(&resigned)?;
Ok(resigned.get("profile").cloned().unwrap_or(Value::Null))
}
pub fn resolve_handle(handle: &Handle, relay_url: Option<&str>) -> anyhow::Result<Value> {
let base = relay_url
.map(str::to_string)
.unwrap_or_else(|| format!("https://{}", handle.domain));
let client = crate::relay_client::RelayClient::new(&base);
match client.well_known_agent(&handle.nick) {
Ok(resolved) => verify_wire_native_payload(&resolved).map(|()| resolved),
Err(_wire_err) => {
let a2a_card = client.well_known_agent_card_a2a(&handle.nick)?;
unwrap_a2a_to_wire_payload(&a2a_card)
}
}
}
fn verify_wire_native_payload(resolved: &Value) -> anyhow::Result<()> {
let card = resolved
.get("card")
.ok_or_else(|| anyhow!("resolved payload missing 'card' field"))?;
crate::agent_card::verify_agent_card(card)
.map_err(|e| anyhow!("resolved card signature invalid: {e}"))?;
let did_in_resp = resolved
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("resolved payload missing 'did'"))?;
let did_in_card = card
.get("did")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("resolved card missing 'did'"))?;
if did_in_resp != did_in_card {
bail!("resolved DID mismatch: payload={did_in_resp} card={did_in_card}");
}
Ok(())
}
fn unwrap_a2a_to_wire_payload(a2a: &Value) -> anyhow::Result<Value> {
let wire_ext = a2a
.get("extensions")
.and_then(Value::as_array)
.and_then(|exts| {
exts.iter().find(|e| {
e.get("uri")
.and_then(Value::as_str)
.map(|u| u.starts_with("https://slancha.ai/wire/ext"))
.unwrap_or(false)
})
});
if let Some(ext) = wire_ext {
let params = ext
.get("params")
.cloned()
.ok_or_else(|| anyhow!("A2A wire extension missing params"))?;
if let Some(card) = params.get("card") {
crate::agent_card::verify_agent_card(card)
.map_err(|e| anyhow!("A2A wire extension card sig invalid: {e}"))?;
}
return Ok(params);
}
Ok(json!({
"did": a2a.get("id").cloned().unwrap_or(Value::Null),
"nick": a2a.get("name").cloned().unwrap_or(Value::Null),
"card": Value::Null,
"slot_id": Value::Null,
"relay_url": a2a.get("endpoint").cloned().unwrap_or(Value::Null),
"claimed_at": Value::Null,
"a2a_only": true,
"a2a_card": a2a.clone(),
}))
}
pub fn render_self_summary() -> Result<String> {
let card = config::read_agent_card()?;
let did = card
.get("did")
.and_then(Value::as_str)
.unwrap_or("did:wire:?")
.to_string();
let local_handle = crate::agent_card::display_handle_from_did(&did).to_string();
let profile = card.get("profile").cloned().unwrap_or(Value::Null);
let mut out = String::new();
let line = |out: &mut String, k: &str, v: &str| {
if !v.is_empty() {
out.push_str(&format!(" {k:14}{v}\n"));
}
};
out.push_str(&format!("{}\n", did));
if let Some(handle) = profile.get("handle").and_then(Value::as_str) {
line(&mut out, "handle:", handle);
} else {
line(&mut out, "handle:", &format!("{local_handle}@(unset)"));
}
if let Some(name) = profile.get("display_name").and_then(Value::as_str) {
line(&mut out, "display_name:", name);
}
if let Some(emoji) = profile.get("emoji").and_then(Value::as_str) {
line(&mut out, "emoji:", emoji);
}
if let Some(motto) = profile.get("motto").and_then(Value::as_str) {
line(&mut out, "motto:", motto);
}
if let Some(vibe) = profile.get("vibe").and_then(Value::as_array) {
let joined: Vec<String> = vibe
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
line(&mut out, "vibe:", &joined.join(", "));
}
if let Some(pronouns) = profile.get("pronouns").and_then(Value::as_str) {
line(&mut out, "pronouns:", pronouns);
}
if let Some(now) = profile.get("now")
&& let Some(text) = now.get("text").and_then(Value::as_str)
{
line(&mut out, "now:", text);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_handle_round_trip() {
let h = parse_handle("coffee-ghost@anthropic.dev").unwrap();
assert_eq!(h.nick, "coffee-ghost");
assert_eq!(h.domain, "anthropic.dev");
assert_eq!(h.as_string(), "coffee-ghost@anthropic.dev");
}
#[test]
fn parse_handle_accepts_underscore_and_digits() {
assert!(parse_handle("dragonfly_42@home.arpa").is_ok());
assert!(parse_handle("v2@wireup.net").is_ok());
}
#[test]
fn parse_handle_rejects_no_at() {
assert!(parse_handle("paul").is_err());
assert!(parse_handle("paul.example.com").is_err());
}
#[test]
fn parse_handle_rejects_empty_parts() {
assert!(parse_handle("@example.com").is_err());
assert!(parse_handle("paul@").is_err());
}
#[test]
fn parse_handle_accepts_reserved_nicks_for_resolution() {
for r in RESERVED_NICKS {
if r.len() < 2 {
continue;
}
let s = format!("{r}@example.com");
assert!(
parse_handle(&s).is_ok(),
"expected reserved nick {r:?} to parse OK for resolution"
);
}
}
#[test]
fn is_valid_nick_rejects_reserved() {
for r in RESERVED_NICKS {
assert!(
!is_valid_nick(r),
"expected is_valid_nick to reject reserved nick {r:?} (claim-time check)"
);
}
}
#[test]
fn parse_handle_rejects_single_char_nick() {
assert!(parse_handle("a@example.com").is_err());
}
#[test]
fn parse_handle_rejects_uppercase_or_emoji_in_nick() {
assert!(parse_handle("Paul@example.com").is_err());
assert!(parse_handle("p👻@example.com").is_err());
}
#[test]
fn parse_handle_rejects_overlong_nick() {
let long = "a".repeat(33);
let s = format!("{long}@example.com");
assert!(parse_handle(&s).is_err());
}
#[test]
fn parse_handle_rejects_bad_domain() {
assert!(parse_handle("paul@-bad.example.com").is_err());
assert!(parse_handle("paul@bad-.example.com").is_err());
assert!(parse_handle("paul@.bad.com").is_err());
}
#[test]
fn is_valid_nick_lower_bound() {
assert!(!is_valid_nick("a"));
assert!(is_valid_nick("ab"));
}
}