From 0f9222fc71d486f56c241ff3287332db2eab0ff4 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 9 Sep 2026 09:57:04 -0400 Subject: [PATCH] feat(serve): list the reservations waiting on one owner's vouch `bot.did.listReservations?owner=` answers with the names held for hosts that named that owner, and nothing else: there is no unscoped form. The selector an operator matches on is the hostname the server chose; the host's key is carried so they can tell they approved the machine they meant. A disclosure route, closable like the rest. Co-Authored-By: Claude Fable 5.1 Change-Id: Ia4fa4b98c2e0d68b58850c71a452c267b3cdf2a4 (cherry picked from commit b45050ed1a4d70b401aaa417a4f63d4655527106) (cherry picked from commit aa3efef4bccbf6685325392f1df79cd0bdebd8aa) --- crates/didbot-config/src/sections.rs | 2 + crates/didbot-serve/src/auth.rs | 4 ++ crates/didbot-serve/src/bin/didbot-pds.rs | 10 ++- crates/didbot-serve/src/lib.rs | 1 + crates/didbot-serve/src/routes.rs | 45 ++++++++++++- crates/didbot-serve/src/wire.rs | 48 ++++++++++++++ crates/didbot/tests/reservation.rs | 80 +++++++++++++++++++++++ 7 files changed, 186 insertions(+), 4 deletions(-) diff --git a/crates/didbot-config/src/sections.rs b/crates/didbot-config/src/sections.rs index a9b720aa..8f0d3a04 100644 --- a/crates/didbot-config/src/sections.rs +++ b/crates/didbot-config/src/sections.rs @@ -192,6 +192,8 @@ pub struct DisclosureSection { pub get_agent_ledger: Option, /// `bot.did.stats`. pub stats: Option, + /// `bot.did.listReservations`. + pub list_reservations: Option, } /// Where the emergency stop's local latches live. diff --git a/crates/didbot-serve/src/auth.rs b/crates/didbot-serve/src/auth.rs index e98f11b4..d108f362 100644 --- a/crates/didbot-serve/src/auth.rs +++ b/crates/didbot-serve/src/auth.rs @@ -256,6 +256,8 @@ pub struct Disclosure { pub get_agent_ledger: bool, /// `bot.did.stats`. pub stats: bool, + /// `bot.did.listReservations`. + pub list_reservations: bool, } impl Default for Disclosure { @@ -268,6 +270,7 @@ impl Default for Disclosure { list_agent_ledgers: true, get_agent_ledger: true, stats: true, + list_reservations: true, } } } @@ -812,6 +815,7 @@ mod tests { list_agent_ledgers: true, get_agent_ledger: true, stats: true, + list_reservations: true, } ); } diff --git a/crates/didbot-serve/src/bin/didbot-pds.rs b/crates/didbot-serve/src/bin/didbot-pds.rs index 48488851..85f7f1e7 100644 --- a/crates/didbot-serve/src/bin/didbot-pds.rs +++ b/crates/didbot-serve/src/bin/didbot-pds.rs @@ -405,7 +405,8 @@ usage: didbot-pds [options] --close-disclosure close one or more disclosure routes: a comma-separated list drawn from `list-agents`, `list-agent-ledgers`, - `get-agent-ledger`, `stats`. Every one defaults open — + `get-agent-ledger`, `stats`, `list-reservations`. Every + one defaults open — this deployment's own roster of agents and what happened to them is published by default, on purpose, so a stranger can audit it — and this flag only ever narrows @@ -520,6 +521,9 @@ fn apply_disclosure_config(disclosure: &mut Disclosure, config: Option<&didbot_c if section.stats == Some(false) { disclosure.stats = false; } + if section.list_reservations == Some(false) { + disclosure.list_reservations = false; + } } /// Resolves the blob limits this run applies: command line, then the @@ -644,10 +648,12 @@ fn close_disclosure(disclosure: &mut Disclosure, raw: &str) -> Result<(), String "list-agent-ledgers" => disclosure.list_agent_ledgers = false, "get-agent-ledger" => disclosure.get_agent_ledger = false, "stats" => disclosure.stats = false, + "list-reservations" => disclosure.list_reservations = false, other => { return Err(format!( "--close-disclosure: `{other}` is not a disclosure route; choose from \ - list-agents, list-agent-ledgers, get-agent-ledger, stats" + list-agents, list-agent-ledgers, get-agent-ledger, stats, \ + list-reservations" )) } } diff --git a/crates/didbot-serve/src/lib.rs b/crates/didbot-serve/src/lib.rs index 3ae6765c..05e931e1 100644 --- a/crates/didbot-serve/src/lib.rs +++ b/crates/didbot-serve/src/lib.rs @@ -32,6 +32,7 @@ //! | `GET /firehose` | every record written, as Server-Sent Events, resumable | //! | `POST /xrpc/bot.did.provisionAgent` | mint an account | //! | `POST /xrpc/bot.did.reserveIdentity` | hold a name and a document for a host's key until an owner vouches for it | +//! | `GET /xrpc/bot.did.listReservations` | the names waiting on one owner's vouch | //! | `POST /xrpc/bot.did.deleteAgent` | erase one: data gone, identity kept | //! | `POST /xrpc/bot.did.freezeAgent`, `unfreezeAgent` | hang or lift the account's own `frozen` lock | //! | `POST /xrpc/bot.did.deactivateAgent`, `activateAgent` | hang or lift the account's own `deactivated` lock | diff --git a/crates/didbot-serve/src/routes.rs b/crates/didbot-serve/src/routes.rs index 13cbc01f..d8f07403 100644 --- a/crates/didbot-serve/src/routes.rs +++ b/crates/didbot-serve/src/routes.rs @@ -37,8 +37,9 @@ use crate::wire::{ DeleteRecordRequest, DescribeRepoQuery, DescribeRepoResponse, DescribeServerResponse, DidRequest, EventsQuery, FirehoseQuery, GetRecordQuery, GetRecordResponse, GetRepoQuery, GetSessionResponse, LedgerQuery, ListRecordsQuery, ListReposQuery, ProvisionAgentRequest, - PutRecordRequest, RecordView, RepoWrite, ReserveIdentityRequest, ReserveIdentityResponse, - SessionResponse, SetPinnedRequest, StatsResponse, SyncGetRecordQuery, WriteResult, + PutRecordRequest, RecordView, RepoWrite, ReservationSummary, ReservationsQuery, + ReserveIdentityRequest, ReserveIdentityResponse, SessionResponse, SetPinnedRequest, + StatsResponse, SyncGetRecordQuery, WriteResult, }; /// Session storage, the e-stop latch and the operator credential, bundled so @@ -447,6 +448,7 @@ xrpc_methods! { "bot.did.listAgentLedgers" => get(list_ledgers) as Disclosure, "bot.did.listAgents" => get(list_agents) as Disclosure, "bot.did.listPendingAuthorizations" => get(list_pending_authorizations) as AgentSelf, + "bot.did.listReservations" => get(list_reservations) as Disclosure, "bot.did.pollOperatorClaim" => post(poll_operator_claim) as Public, "bot.did.provisionAgent" => post(provision_agent) as Attested, "bot.did.reserveIdentity" => post(reserve_identity) as Public, @@ -2145,6 +2147,45 @@ async fn list_agents(State(state): State) -> Response { Json(json!({ "agents": agents })).into_response() } +/// `GET /xrpc/bot.did.listReservations?owner=` +/// +/// The reservations waiting on `owner`'s vouch — the ones that named that +/// owner when they were made, which is public information and narrows the +/// list without proving anything about the caller. There is no unscoped +/// form: a name held for a host is shown to the operator it is waiting on, +/// not to everyone who asks. `Credential::Disclosure`, so a deployment +/// that wants the list closed entirely closes it. +async fn list_reservations( + State(state): State, + query: Result, QueryRejection>, +) -> Response { + if let Err(err) = require_lifecycle(&state, |p| p.serves_reads, "bot.did.listReservations") { + return err.into_response(); + } + if let Err(err) = auth::require_disclosure( + state.disclosure.list_reservations, + "bot.did.listReservations", + ) { + return err.into_response(); + } + let Query(query) = match query { + Ok(query) => query, + Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(), + }; + if let Err(error) = didbot_identity::validate_did(&query.owner) { + return ApiError::bad_request(format!("owner: {error}")).into_response(); + } + let owner = canonical_did(&query.owner); + let reservations: Vec = state + .registry + .reservations() + .iter() + .filter(|account| account.expected_owner.as_deref() == Some(owner.as_str())) + .filter_map(ReservationSummary::from_account) + .collect(); + Json(json!({ "reservations": reservations })).into_response() +} + /// Resolves the `repo` parameter, which is an at-identifier. /// /// A DID or a handle, because that is what every `com.atproto.repo.*` lexicon diff --git a/crates/didbot-serve/src/wire.rs b/crates/didbot-serve/src/wire.rs index 5d64ae99..ac07a1f8 100644 --- a/crates/didbot-serve/src/wire.rs +++ b/crates/didbot-serve/src/wire.rs @@ -114,6 +114,54 @@ impl ReserveIdentityRequest { } } +/// Query of `bot.did.listReservations`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReservationsQuery { + /// The owner DID to answer for. Required: the list is scoped to the + /// reservations that named this owner, and there is no unscoped form. + pub owner: String, +} + +/// One pending reservation, as `bot.did.listReservations` reports it. +/// +/// The name is the selector: an operator matches the hostname the host +/// printed against this list and claims by it. The key is here so they can +/// tell they approved the machine they meant, and for nothing else — a +/// digest a person compares proves nothing on its own, because a key can be +/// ground toward one, and a name drawn from the server's pool cannot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReservationSummary { + /// The DID held for the host. + pub did: String, + /// The name the server chose, under its zone. + pub hostname: String, + /// What kind of account is waiting. + pub kind: AccountKind, + /// The host's own key, as a `did:key`. + pub key: String, + /// When the reservation was made, RFC 3339. + pub created_at: String, + /// When it lapses if nobody vouches, RFC 3339. + pub expires_at: String, +} + +impl ReservationSummary { + /// Summarizes a pending reservation. `None` for an account that is not + /// one, so a caller cannot list something this shape does not describe. + pub fn from_account(account: &AgentAccount) -> Option { + let expires_at = account.reservation_expires_at()?; + Some(Self { + did: account.did.as_str().to_owned(), + hostname: account.did.host().to_owned(), + kind: account.kind, + key: format!("did:key:{}", account.node_key.as_deref()?), + created_at: account.created_at.format(&Rfc3339).unwrap_or_default(), + expires_at: expires_at.format(&Rfc3339).unwrap_or_default(), + }) + } +} + /// Response of `bot.did.reserveIdentity`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/didbot/tests/reservation.rs b/crates/didbot/tests/reservation.rs index 53a58969..c25367ff 100644 --- a/crates/didbot/tests/reservation.rs +++ b/crates/didbot/tests/reservation.rs @@ -483,3 +483,83 @@ async fn an_expired_reservation_is_reaped_and_its_name_is_mintable_again() { assert_eq!(status, StatusCode::OK, "{again}"); assert_eq!(again["hostname"], hostname, "the name is mintable again"); } + +// --------------------------------------------------------------------------- +// The pending list +// --------------------------------------------------------------------------- + +/// The list answers for one owner and shows only what named that owner. +/// +/// Two hosts reserve, each expecting a different operator; each operator's +/// list holds one entry, and an unscoped request is refused rather than +/// answered with everything. +#[tokio::test] +async fn the_pending_list_answers_only_for_the_owner_a_reservation_named() { + let booted = boot(&["for-alice", "for-bob", "for-nobody"], a_day(), 6); + const ALICE: &str = "did:plc:alicealicealicealicealic"; + const BOB: &str = "did:plc:bobbobbobbobbobbobbobbobb"; + + let alice_key = host_key(); + let (status, body) = post_json( + &booted.app, + "/xrpc/bot.did.reserveIdentity", + json!({ "key": alice_key.did_key, "kind": "host", "owner": ALICE }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let alice_host = body["hostname"].as_str().expect("hostname").to_owned(); + let (status, body) = post_json( + &booted.app, + "/xrpc/bot.did.reserveIdentity", + json!({ "key": host_key().did_key, "kind": "host", "owner": BOB }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let bob_host = body["hostname"].as_str().expect("hostname").to_owned(); + // A host that names nobody is waiting on this deployment's own owner — + // the only repository the poll reads — and is listed there. + let (status, body) = reserve(&booted.app, &host_key()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let nobody_host = body["hostname"].as_str().expect("hostname").to_owned(); + + let (status, listed) = get_json( + &booted.app, + &format!("/xrpc/bot.did.listReservations?owner={ALICE}"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{listed}"); + let entries = listed["reservations"].as_array().expect("a list"); + assert_eq!(entries.len(), 1, "alice sees only what named her: {listed}"); + assert_eq!(entries[0]["hostname"], alice_host); + assert_eq!(entries[0]["key"], alice_key.did_key); + assert_eq!(entries[0]["kind"], "host"); + assert!(entries[0]["expiresAt"].as_str().is_some()); + + let (status, listed) = get_json( + &booted.app, + &format!("/xrpc/bot.did.listReservations?owner={BOB}"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{listed}"); + let entries = listed["reservations"].as_array().expect("a list"); + assert_eq!(entries.len(), 1, "bob sees only what named him: {listed}"); + assert_eq!(entries[0]["hostname"], bob_host); + + let (status, listed) = get_json( + &booted.app, + "/xrpc/bot.did.listReservations?owner=did:web:owner.example", + ) + .await; + assert_eq!(status, StatusCode::OK, "{listed}"); + assert_eq!(listed["reservations"][0]["hostname"], nobody_host); + assert_eq!(listed["reservations"].as_array().map(Vec::len), Some(1)); + + let (status, body) = get_json(&booted.app, "/xrpc/bot.did.listReservations").await; + assert_eq!(status, StatusCode::BAD_REQUEST, "no unscoped list: {body}"); + + assert_eq!( + booted.registry.reservations().len(), + 3, + "scoping the list hides nothing from the deployment itself" + ); +} -- 2.51.2