From b2bc853496e83f8f88af6812aba07555a1fbb3a7 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 16 Sep 2026 22:58:51 -0400 Subject: [PATCH] fix(cli)!: end the walk at the account's own endpoint and read the server's edge `didbot operate --check ` said "verified" for an account on a server the human had withdrawn from, and `--server` spelled unlike the document's endpoint ended the walk after one edge. The walk now compares each operator's endpoint with the account's own, and finishes by reading the operator's `bot.did.operator/` record, printed as the last hop. Co-Authored-By: Claude Fable 5.1 Change-Id: I4e7c0c63b0c4e698be274164744922b0102cef32 --- .../src/bin/didbot-operator.rs | 11 +- crates/didbot-operator/src/operate/walk.rs | 108 ++++++++++++------ docs/cli.md | 3 +- docs/ownership.md | 18 ++- 4 files changed, 95 insertions(+), 45 deletions(-) diff --git a/crates/didbot-operator/src/bin/didbot-operator.rs b/crates/didbot-operator/src/bin/didbot-operator.rs index e796acfe..d03ee1f7 100644 --- a/crates/didbot-operator/src/bin/didbot-operator.rs +++ b/crates/didbot-operator/src/bin/didbot-operator.rs @@ -364,8 +364,8 @@ fn parse_oidc(words: &[String]) -> Result, Refusal> { /// `operate --check`: a server's report, or an account's walk, and a /// refusal until it passes. async fn check_only(name: &str, target: Target, json: Json) -> Result<(), Refusal> { - if let Target::Account { server } = target { - return check_walk(name, &server, json).await; + if let Target::Account { .. } = target { + return check_walk(name, json).await; } let hostname = name; let report = operate::check(hostname).await; @@ -402,11 +402,12 @@ async fn check_only(name: &str, target: Target, json: Json) -> Result<(), Refusa ))) } -/// `operate --check ` for an account: every edge up to the operator. -async fn check_walk(name: &str, server: &str, json: Json) -> Result<(), Refusal> { +/// `operate --check ` for an account: every edge up to the operator, +/// and the operator's own record for the server. +async fn check_walk(name: &str, json: Json) -> Result<(), Refusal> { let fetcher = operate::identify::HttpDocumentFetcher::new(didbot_http::client()); let mut printed = Vec::new(); - let walked = operate::walk::walk(&fetcher, name, server, |hop| { + let walked = operate::walk::walk(&fetcher, name, |hop| { let line = format!( " {} ({}) is operated by {} -- {}", hop.account, hop.kind, hop.operator, hop.record diff --git a/crates/didbot-operator/src/operate/walk.rs b/crates/didbot-operator/src/operate/walk.rs index 93214bac..926f7db9 100644 --- a/crates/didbot-operator/src/operate/walk.rs +++ b/crates/didbot-operator/src/operate/walk.rs @@ -6,7 +6,11 @@ //! repository holds `bot.did.operator/` whose subject is the account. //! The walk reads both, over plain HTTP, and climbs to the operator; it //! stops the first time the operator's repository is not on the server the -//! account is, and refuses to climb more than [`MAX_EDGES`]. +//! account is, and refuses to climb more than [`MAX_EDGES`]. Which server +//! that is comes from each document's own `#atproto_pds` endpoint, never +//! from an argument. The last hop is the server itself: the operator the +//! walk ended at has to hold `bot.did.operator/` naming it, or +//! the tree is one no human answers for. use didbot_identity::did::AccountDid; use didbot_identity::document::DidDocument; @@ -84,13 +88,13 @@ struct Fetched { value: serde_json::Value, } -/// Walks from the account named `name`, hosted at `server_host`, upward -/// until the operator's repository is elsewhere. `on_hop` sees each edge -/// as it verifies. +/// Walks from the account named `name` upward until the operator's +/// repository is elsewhere, then reads that operator's record for the +/// server itself. `on_hop` sees each edge as it verifies; the last is the +/// server's, with kind `server`. pub async fn walk( fetcher: &F, name: &str, - server_host: &str, mut on_hop: impl FnMut(&Hop), ) -> Result, WalkError> { let mut hops = Vec::new(); @@ -100,9 +104,10 @@ pub async fn walk( let document = resolve(fetcher, &did).await?; let pds = document .pds_endpoint() - .ok_or_else(|| WalkError::NoPds { did: did.clone() })?; + .ok_or_else(|| WalkError::NoPds { did: did.clone() })? + .to_owned(); - let registration = record(fetcher, pds, &did, "bot.did.registration", "self").await?; + let registration = record(fetcher, &pds, &did, "bot.did.registration", "self").await?; let operator = registration.value["operator"] .as_str() .ok_or_else(|| WalkError::Record { @@ -123,29 +128,32 @@ pub async fn walk( did: operator.clone(), })? .to_owned(); - let record = record(fetcher, &operator_pds, &operator, "bot.did.operator", &name).await?; - match record.value["subject"].as_str() { - Some(subject) if subject == did => {} - found => { - return Err(WalkError::WrongSubject { - uri: record.uri, - found: found.unwrap_or("nothing").to_owned(), - expected: did, - }) - } - } - + let record = vouch(fetcher, &operator_pds, &operator, &name, &did).await?; let hop = Hop { account: did, kind, label, operator: operator.clone(), - record: record.uri, + record, }; on_hop(&hop); hops.push(hop); - if authority_of(&operator_pds) != server_host { + if operator_pds != pds { + // The operator's repository is elsewhere: the walk is over + // once that operator also answers for the server itself. + let server = authority_of(&pds).to_owned(); + let server_did = format!("did:web:{}", server.replace(':', "%3A")); + let record = vouch(fetcher, &operator_pds, &operator, &server, &server_did).await?; + let hop = Hop { + account: server_did, + kind: "server".to_owned(), + label: None, + operator, + record, + }; + on_hop(&hop); + hops.push(hop); return Ok(hops); } if hops.len() >= MAX_EDGES { @@ -164,6 +172,26 @@ pub async fn walk( } } +/// `operator`'s `bot.did.operator/` at `operator_pds`, checked to +/// name `subject`. Answers the record's URI. +async fn vouch( + fetcher: &F, + operator_pds: &str, + operator: &str, + name: &str, + subject: &str, +) -> Result { + let record = record(fetcher, operator_pds, operator, "bot.did.operator", name).await?; + match record.value["subject"].as_str() { + Some(found) if found == subject => Ok(record.uri), + found => Err(WalkError::WrongSubject { + uri: record.uri, + found: found.unwrap_or("nothing").to_owned(), + expected: subject.to_owned(), + }), + } +} + /// `did`'s document, by whatever method it is. async fn resolve(fetcher: &F, did: &str) -> Result { let wrap = |source| WalkError::Document { @@ -318,16 +346,32 @@ mod tests { .record(HUMAN_PDS, HUMAN, "bot.did.operator", "laptop.pds.example", serde_json::json!({ "subject": "did:web:laptop.pds.example" })); + // The human has not claimed the server: nobody answers for it. + let err = walk(&web, "a1.pds.example", |_| {}).await.unwrap_err(); + assert!( + matches!(&err, WalkError::Record { uri, .. } if uri == &format!("at://{HUMAN}/bot.did.operator/pds.example")), + "{err}" + ); + + web.record( + HUMAN_PDS, + HUMAN, + "bot.did.operator", + "pds.example", + serde_json::json!({ "subject": "did:web:pds.example" }), + ); let mut seen = Vec::new(); - let hops = walk(&web, "a1.pds.example", "pds.example", |hop| { - seen.push(hop.account.clone()) - }) - .await - .expect("every hop verifies"); + let hops = walk(&web, "a1.pds.example", |hop| seen.push(hop.account.clone())) + .await + .expect("every hop verifies"); assert_eq!( seen, - ["did:web:a1.pds.example", "did:web:laptop.pds.example"] + [ + "did:web:a1.pds.example", + "did:web:laptop.pds.example", + "did:web:pds.example" + ] ); assert_eq!(hops[0].operator, "did:web:laptop.pds.example"); assert_eq!(hops[0].kind, "agent"); @@ -337,6 +381,8 @@ mod tests { hops[1].record, format!("at://{HUMAN}/bot.did.operator/laptop.pds.example") ); + assert_eq!(hops[2].kind, "server"); + assert_eq!(hops[2].operator, HUMAN); assert!( !web.asked .lock() @@ -362,9 +408,7 @@ mod tests { serde_json::json!({ "operator": "did:web:laptop.pds.example", "kind": "agent" }), ); - let err = walk(&web, "a1.pds.example", "pds.example", |_| {}) - .await - .unwrap_err(); + let err = walk(&web, "a1.pds.example", |_| {}).await.unwrap_err(); assert!( matches!(&err, WalkError::Record { uri, .. } if uri == "at://did:web:laptop.pds.example/bot.did.operator/a1.pds.example"), @@ -387,9 +431,7 @@ mod tests { "a1.pds.example", serde_json::json!({ "subject": "did:web:a2.pds.example" }), ); - let err = walk(&web, "a1.pds.example", "pds.example", |_| {}) - .await - .unwrap_err(); + let err = walk(&web, "a1.pds.example", |_| {}).await.unwrap_err(); assert!(matches!(err, WalkError::WrongSubject { .. }), "{err}"); } } diff --git a/docs/cli.md b/docs/cli.md index 569cfbd5..01ba7ba0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -236,7 +236,8 @@ cross-checks `describeServer`. For an account it walks the tree: the account's `bot.did.registration/self` names an operator, that operator's `bot.did.operator/` names the account back, and the walk climbs to the operator while its repository is on the same server, printing each edge and -refusing past three. +refusing past three. The last edge printed is the server's own: the +operator the walk ended at holds `bot.did.operator/` naming it. `didbot login --server [HANDLE]` prints a URL and listens on a loopback port. The deployment runs its own sign-in against the operator's diff --git a/docs/ownership.md b/docs/ownership.md index 366e3978..260dd65e 100644 --- a/docs/ownership.md +++ b/docs/ownership.md @@ -139,7 +139,7 @@ timings. ## The tree -
+
@@ -244,7 +244,7 @@ timings. THE WALK, from any account upward its registration names an operator · that operator's repository holds operator/<name> with the account as subject - climb while the operator's repository is on this server · the walk ends at the first repository the server does not hold + climb while the operator's repository is on this server · the walk ends at the first repository the server does not hold, once it also names the server refuse past three edges · 1d4e02 → i-0a9f → web → the human is the longest
@@ -279,13 +279,19 @@ every hop verifies. From `1d4e02.pds.example`: `web.pds.example` reach `did:plc:human`. 5. `did:plc:human`'s document names a PDS that is not `pds.example`, so `GET https:///xrpc/com.atproto.repo.getRecord?repo=did:plc:human&collection=bot.did.operator&rkey=web.pds.example` - is the last read, and the walk ends. Whether the human also claims the - server itself is the check in + is the last edge. +6. The human answers for the server too, or the tree is nobody's: + `GET https:///xrpc/com.atproto.repo.getRecord?repo=did:plc:human&collection=bot.did.operator&rkey=pds.example` + → `subject` must be `did:web:pds.example`. That is the record the + server's own poll reads; the walk ends here. The key it is read at and + how the server reacts when it goes are in [verifying who an agent belongs to](operator-verification.md). A pair only one side states fails, in both directions. A walk still on this -server after three edges is refused as too deep. Each read is of the -current record, so a deletion is visible to the next walk. +server after three edges is refused as too deep. Which server an account +is on is its own document's `#atproto_pds` endpoint at every hop, never +the name typed or `--server`. Each read is of the current record, so a +deletion is visible to the next walk. ## The scenarios -- 2.51.2