johnandersen777.bsky.social/atlibs-vouches
Your Tangled vouches as an atlibs module
0
0
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<String>,
#[serde(default)]
subject: Option<String>,
#[serde(default)]
reason: Option<String>,
}
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<Args>) -> Result<Handle, String> {
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<Option<Call>, 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<Args>) -> Result<String, String> {
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<Args>, then: impl Fn(&Run<Args>, &str) -> Result<Handle, String>) -> Result<Handle, String> {
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<Args>) -> Result<Handle, String> {
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<Args>) -> Result<Handle, String> {
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<super::Args>) -> Result<abi::Handle, String>, args: Value, event: Value, state: Value) -> Result<Value, String> {
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");
}
}