diff --git a/crates/didbot-swarm/src/lib.rs b/crates/didbot-swarm/src/lib.rs index a6fa54a4..2f30471a 100644 --- a/crates/didbot-swarm/src/lib.rs +++ b/crates/didbot-swarm/src/lib.rs @@ -109,6 +109,8 @@ pub enum Verb { ProvisionAgent, /// `bot.did.deleteAgent`. DeleteAgent, + /// `com.atproto.repo.applyWrites`. + ApplyWrites, /// `com.atproto.repo.createRecord`. CreateRecord, /// `com.atproto.repo.putRecord`. @@ -123,9 +125,10 @@ pub enum Verb { impl Verb { /// Every verb, in the order the tally prints them. - pub const ALL: [Verb; 7] = [ + pub const ALL: [Verb; 8] = [ Verb::ProvisionAgent, Verb::DeleteAgent, + Verb::ApplyWrites, Verb::CreateRecord, Verb::PutRecord, Verb::DeleteRecord, @@ -139,6 +142,7 @@ impl Verb { match self { Verb::ProvisionAgent => "provisionAgent", Verb::DeleteAgent => "deleteAgent", + Verb::ApplyWrites => "applyWrites", Verb::CreateRecord => "createRecord", Verb::PutRecord => "putRecord", Verb::DeleteRecord => "deleteRecord", @@ -153,6 +157,7 @@ impl Verb { match self { Verb::ProvisionAgent => "/xrpc/bot.did.provisionAgent", Verb::DeleteAgent => "/xrpc/bot.did.deleteAgent", + Verb::ApplyWrites => "/xrpc/com.atproto.repo.applyWrites", Verb::CreateRecord => "/xrpc/com.atproto.repo.createRecord", Verb::PutRecord => "/xrpc/com.atproto.repo.putRecord", Verb::DeleteRecord => "/xrpc/com.atproto.repo.deleteRecord", @@ -553,6 +558,29 @@ impl Pds { Ok(()) } + /// Writes a batch as one `applyWrites`, the single call the policy + /// dashboard deploys a whole change with. + /// + /// `swap_commit` is the commit the batch was composed against. Passing it + /// is what makes a concurrent write refuse this one with `InvalidSwap` + /// rather than overwriting records the caller never read; the page always + /// passes one, and so should anything else writing a set. + pub async fn apply_writes( + &self, + did: &str, + auth: Auth<'_>, + swap_commit: Option<&str>, + writes: &[Value], + ) -> Result { + let verb = Verb::ApplyWrites; + let mut body = serde_json::json!({ "repo": did, "writes": writes }); + if let Some(swap) = swap_commit { + body["swapCommit"] = Value::String(swap.to_owned()); + } + let request = self.post(verb, auth)?.json(&body); + self.call(verb, request).await + } + /// Writes a new record, answering with the key the server minted for it. pub async fn create_record( &self, diff --git a/crates/didbot-swarm/src/oauth.rs b/crates/didbot-swarm/src/oauth.rs index 085413b4..90fb2bda 100644 --- a/crates/didbot-swarm/src/oauth.rs +++ b/crates/didbot-swarm/src/oauth.rs @@ -30,12 +30,12 @@ use sha2::{Digest, Sha256}; use crate::{read, Pds, SwarmError, Verb}; -/// The scope every sign-in asks for: writing any collection, since a token +/// The scope a swarm sign-in asks for: writing any collection, since a token /// writes only what it was granted. What this proves is that a token this /// route minted authenticates a write, not that a particular capability /// grammar narrows correctly -- `oauth::decision`'s own tests already cover -/// that. -const SCOPE: &str = "atproto repo:*"; +/// that. [`Pds::sign_in_at`] takes a narrower one. +pub const SWARM_SCOPE: &str = "atproto repo:*"; /// The loopback redirect URI every sign-in names. Never dialled: the code /// this server hands back travels in the redirect's own query string, which @@ -88,13 +88,14 @@ fn proof(key: &jose_jwk::Key, htu: &str, ath: Option<&str>) -> Result String { +/// [`REDIRECT_URI`] and `scope` in its query string so the server needs no +/// fetch to resolve it. The same form the policy dashboard uses when it is +/// served from a loopback address. +fn client_id(scope: &str) -> String { let mut url = url::Url::parse("http://localhost").expect("a static literal parses"); url.query_pairs_mut() .append_pair("redirect_uri", REDIRECT_URI) - .append_pair("scope", SCOPE); + .append_pair("scope", scope); url.to_string() } @@ -153,9 +154,23 @@ impl Pds { &self, agent_did: &str, agent_token: &str, + ) -> Result { + self.sign_in_at(agent_did, agent_token, SWARM_SCOPE).await + } + + /// [`Pds::sign_in`] asking for `scope` instead of [`SWARM_SCOPE`], for a + /// caller standing in for an app that asks for less -- the policy + /// dashboard asks only for the two collections it writes. + /// + /// Counted in the tally as one [`Verb::SignIn`], timed across every call. + pub async fn sign_in_at( + &self, + agent_did: &str, + agent_token: &str, + scope: &str, ) -> Result { let started = std::time::Instant::now(); - let outcome = self.sign_in_uncounted(agent_did, agent_token).await; + let outcome = self.sign_in_uncounted(agent_did, agent_token, scope).await; self.tally .record(Verb::SignIn, started.elapsed(), outcome.is_ok()); outcome @@ -165,6 +180,7 @@ impl Pds { &self, agent_did: &str, agent_token: &str, + scope: &str, ) -> Result { let metadata_url = format!("{}/.well-known/oauth-authorization-server", self.base_url()); let response = self @@ -181,7 +197,7 @@ impl Pds { let key = jacquard_oauth::utils::generate_key(&[DPOP_ALG]) .expect("jacquard-oauth generates an ES256 key"); let (challenge, verifier) = jacquard_oauth::utils::generate_pkce(); - let client_id = client_id(); + let client_id = client_id(scope); let par_url = format!("{}/oauth/par", self.base_url()); let response = self @@ -195,7 +211,7 @@ impl Pds { ("client_id", client_id.as_str()), ("response_type", "code"), ("redirect_uri", REDIRECT_URI), - ("scope", SCOPE), + ("scope", scope), ("code_challenge", challenge.as_str()), ("code_challenge_method", "S256"), ("login_hint", agent_did),