use abi::repo::{delete_record, list_records, put_record}; use abi::{AtUri, Call, Event, Handle, Run, entries}; use serde::Deserialize; use serde_json::{Value, json}; // Tangled's vouches: sh.tangled.graph.vouch, rkey = the subject's DID, {kind: "vouch" | "denounce", reason?}. // Nobody can compute the keys of the vouches an account made, so `list` is a listRecords: the one // collection here we do not own. const VOUCH: &str = "sh.tangled.graph.vouch"; const PAGES: u64 = 10; #[derive(Deserialize)] struct Args { #[serde(default)] did: String, #[serde(default)] now: String, #[serde(default)] of: Option, #[serde(default)] subject: Option, #[serde(default)] reason: Option, } fn page(repo: &str, cursor: Option<&str>) -> Call { list_records(repo, VOUCH, 100, cursor).id("page").service(repo) } fn row(record: &Value) -> Value { let uri = record["uri"].as_str().unwrap_or_default(); let value = &record["value"]; let subject = value["subject"].as_str().unwrap_or_else(|| uri.rsplit('/').next().unwrap_or_default()); json!({ "subject": subject, "kind": value["kind"].as_str().unwrap_or("vouch"), "reason": value["reason"], "createdAt": value["createdAt"], "uri": uri, "cid": record["cid"] }) } // list {of?}: every vouch `of` (default: you) made, newest first, up to 1000. fn list_handler(run: Run) -> Result { let of = run.args.of.clone().filter(|s| !s.is_empty()).unwrap_or(run.args.did.clone()); if !of.starts_with("did:") { return Err("of: a DID (or sign in)".into()); } Ok(match run.event { Event::Start => Handle::new().call(page(&of, None)).state(json!({ "rows": [], "pages": 1 })), Event::Result { id, result, .. } if id == "page" => { let out = result.output()?; let mut state = run.state; let rows = state["rows"].as_array_mut().ok_or("bad state")?; rows.extend(out["records"].as_array().into_iter().flatten().map(row)); let pages = state["pages"].as_u64().unwrap_or(1); match out["cursor"].as_str() { Some(cursor) if pages < PAGES && out["records"].as_array().is_some_and(|r| !r.is_empty()) => { state["pages"] = json!(pages + 1); Handle::new().call(page(&of, Some(cursor))).state(state) } _ => { let mut rows = state["rows"].take(); if let Some(list) = rows.as_array_mut() { list.sort_by(|a, b| b["createdAt"].as_str().cmp(&a["createdAt"].as_str())); } Handle::new().result(json!({ "of": of, "vouches": rows })).done() } } } _ => Handle::new(), }) } // The subject's DID: as given, or its handle resolved. fn subject_call(subject: &str) -> Result, String> { let subject = subject.trim().trim_start_matches('@'); if subject.starts_with("did:") { return Ok(None); } if subject.is_empty() || !subject.contains('.') { return Err("subject: a DID or a handle".into()); } Ok(Some(Call::new("resolve", "com.atproto.identity.resolveHandle").params(json!({ "handle": subject })))) } fn subject_of(run: &Run) -> Result { run.args.subject.clone().map(|s| s.trim().trim_start_matches('@').to_string()).filter(|s| !s.is_empty()).ok_or("subject is required".into()) } // Resolve the subject (if a handle), then `then(did)`. fn with_subject(run: Run, then: impl Fn(&Run, &str) -> Result) -> Result { if !run.args.did.starts_with("did:") { return Err("sign in first".into()); } let subject = subject_of(&run)?; match &run.event { Event::Start => match subject_call(&subject)? { Some(call) => Ok(Handle::new().call(call)), None => then(&run, &subject), }, Event::Result { id, result, .. } if id == "resolve" => { let did = result.output()?["did"].as_str().filter(|d| d.starts_with("did:")).ok_or("the handle did not resolve")?.to_string(); then(&run, &did) } Event::Result { id, result, .. } if id == "write" => { let out = result.output()?; Ok(Handle::new().result(json!({ "subject": run.state["subject"], "uri": out["uri"], "cid": out["cid"], "done": true })).done()) } _ => Ok(Handle::new()), } } // vouch {subject, reason?}: your vouch for subject (putRecord at its DID: a vouch again replaces it). fn vouch_handler(run: Run) -> Result { with_subject(run, |run, subject| { if subject == run.args.did { return Err("a self-vouch never counts".into()); } let mut record = json!({ "$type": VOUCH, "kind": "vouch", "subject": subject, "createdAt": run.args.now }); if let Some(reason) = run.args.reason.as_deref().map(str::trim).filter(|r| !r.is_empty()) { if reason.chars().count() > 300 { return Err("reason: at most 300 characters".into()); } record["reason"] = json!(reason); } let uri = AtUri::new(&run.args.did, VOUCH, subject); Ok(Handle::new().call(put_record(&uri, record).id("write")).state(json!({ "subject": subject }))) }) } // unvouch {subject}: delete your vouch for subject. fn unvouch_handler(run: Run) -> Result { with_subject(run, |run, subject| { let uri = AtUri::new(&run.args.did, VOUCH, subject); Ok(Handle::new().call(delete_record(&uri).id("write")).state(json!({ "subject": subject }))) }) } entries!(list => list_handler, vouch => vouch_handler, unvouch => unvouch_handler); #[cfg(test)] mod tests { use serde_json::{Value, json}; fn step(f: fn(abi::Run) -> Result, args: Value, event: Value, state: Value) -> Result { let mut input = args; input["event"] = event; input["state"] = state; let run = serde_json::from_value(input).unwrap(); f(run).map(|h| serde_json::to_value(h).unwrap()) } const ME: &str = "did:plc:alice"; const NOW: &str = "2026-11-10T00:00:00.000Z"; #[test] fn list_pages_and_sorts() { let args = json!({ "did": ME, "now": NOW }); let start = step(super::list_handler, args.clone(), json!({ "type": "start" }), Value::Null).unwrap(); assert_eq!(start["calls"][0]["params"]["collection"], "sh.tangled.graph.vouch"); assert_eq!(start["calls"][0]["service"], ME); let page1 = json!({ "records": [{ "uri": "at://did:plc:alice/sh.tangled.graph.vouch/did:plc:bob", "cid": "b1", "value": { "kind": "vouch", "createdAt": "2026-01-01" } }], "cursor": "c1" }); let next = step(super::list_handler, args.clone(), json!({ "type": "result", "id": "page", "ok": true, "output": page1 }), start["state"].clone()).unwrap(); assert_eq!(next["calls"][0]["params"]["cursor"], "c1"); let page2 = json!({ "records": [{ "uri": "at://did:plc:alice/sh.tangled.graph.vouch/did:plc:carol", "cid": "c2", "value": { "subject": "did:plc:carol", "createdAt": "2026-02-01" } }] }); let done = step(super::list_handler, args, json!({ "type": "result", "id": "page", "ok": true, "output": page2 }), next["state"].clone()).unwrap(); let rows = &done["result"]["vouches"]; assert_eq!(rows[0]["subject"], "did:plc:carol"); assert_eq!(rows[1]["subject"], "did:plc:bob"); assert_eq!(rows[1]["kind"], "vouch"); } #[test] fn vouch_resolves_a_handle_then_puts_at_the_did() { let args = json!({ "did": ME, "now": NOW, "subject": "@bob.test", "reason": "ok" }); let start = step(super::vouch_handler, args.clone(), json!({ "type": "start" }), Value::Null).unwrap(); assert_eq!(start["calls"][0]["nsid"], "com.atproto.identity.resolveHandle"); let put = step(super::vouch_handler, args, json!({ "type": "result", "id": "resolve", "ok": true, "output": { "did": "did:plc:bob" } }), Value::Null).unwrap(); let call = &put["calls"][0]; assert_eq!(call["nsid"], "com.atproto.repo.putRecord"); assert_eq!(call["input"]["rkey"], "did:plc:bob"); assert_eq!(call["input"]["record"], json!({ "$type": "sh.tangled.graph.vouch", "kind": "vouch", "subject": "did:plc:bob", "reason": "ok", "createdAt": NOW })); } #[test] fn refuses_self_vouch_and_signed_out() { let me = step(super::vouch_handler, json!({ "did": ME, "now": NOW, "subject": ME }), json!({ "type": "start" }), Value::Null); assert!(me.unwrap_err().contains("self-vouch")); let out = step(super::unvouch_handler, json!({ "subject": "did:plc:bob" }), json!({ "type": "start" }), Value::Null); assert!(out.unwrap_err().contains("sign in")); } #[test] fn unvouch_deletes_at_the_did() { let d = step(super::unvouch_handler, json!({ "did": ME, "now": NOW, "subject": "did:plc:bob" }), json!({ "type": "start" }), Value::Null).unwrap(); assert_eq!(d["calls"][0]["nsid"], "com.atproto.repo.deleteRecord"); assert_eq!(d["calls"][0]["input"]["rkey"], "did:plc:bob"); } }