···1515/// If specified in an API endpoint, this will guarantee that the API can only be called
1616/// by an authenticated user.
1717pub(crate) struct AuthenticatedUser {
1818+ /// The DID of the authenticated user.
1819 did: String,
1920}
2021···4041 auth.strip_prefix("Bearer ")
4142 });
42434343- let token = match token {
4444- Some(tok) => tok,
4545- None => {
4646- return Err(Error::with_status(
4747- StatusCode::UNAUTHORIZED,
4848- anyhow!("no bearer token"),
4949- ));
5050- }
4444+ let Some(token) = token else {
4545+ return Err(Error::with_status(
4646+ StatusCode::UNAUTHORIZED,
4747+ anyhow!("no bearer token"),
4848+ ));
5149 };
52505351 // N.B: We ignore all fields inside of the token up until this point because they can be
···113111pub(crate) fn sign(
114112 key: &Secp256k1Keypair,
115113 typ: &str,
116116- claims: serde_json::Value,
114114+ claims: &serde_json::Value,
117115) -> anyhow::Result<String> {
118116 // RFC 9068
119117 let hdr = serde_json::json!({
+1-1
src/config.rs
···11//! Configuration structures for the PDS.
22/// The metrics configuration.
33pub(crate) mod metrics {
44- use super::*;
44+ use super::{Deserialize, Url};
5566 #[derive(Deserialize, Debug, Clone)]
77 /// The Prometheus configuration.
···11+//! Identity endpoints (/xrpc/com.atproto.identity.*)
12use std::collections::HashMap;
2334use anyhow::{Context as _, anyhow};
···2425 plc::{self, PlcOperation, PlcService},
2526};
26272828+/// (GET) Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document.
2929+/// ### Query Parameters
3030+/// - handle: The handle to resolve.
3131+/// ### Responses
3232+/// - 200 OK: {did: did}
3333+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `HandleNotFound`]}
3434+/// - 401 Unauthorized
2735async fn resolve_handle(
2836 State(db): State<Db>,
2937 State(client): State<Client>,
···5866}
59676068#[expect(unused_variables, clippy::todo, reason = "Not yet implemented")]
6969+/// Request an email with a code to in order to request a signed PLC operation. Requires Auth.
7070+/// - POST /xrpc/com.atproto.identity.requestPlcOperationSignature
7171+/// ### Responses
7272+/// - 200 OK
7373+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
7474+/// - 401 Unauthorized
6175async fn request_plc_operation_signature(user: AuthenticatedUser) -> Result<()> {
6276 todo!()
6377}
64786579#[expect(unused_variables, clippy::todo, reason = "Not yet implemented")]
8080+/// Signs a PLC operation to update some value(s) in the requesting DID's document.
8181+/// - POST /xrpc/com.atproto.identity.signPlcOperation
8282+/// ### Request Body
8383+/// - token: string // A token received through com.atproto.identity.requestPlcOperationSignature
8484+/// - rotationKeys: string[]
8585+/// - alsoKnownAs: string[]
8686+/// - verificationMethods: services
8787+/// ### Responses
8888+/// - 200 OK: {operation: string}
8989+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
9090+/// - 401 Unauthorized
6691async fn sign_plc_operation(
6792 user: AuthenticatedUser,
6893 State(skey): State<SigningKey>,
···77102 clippy::too_many_arguments,
78103 reason = "Many parameters are required for this endpoint"
79104)]
105105+/// Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth.
106106+/// - POST /xrpc/com.atproto.identity.updateHandle
107107+/// ### Query Parameters
108108+/// - handle: handle // The new handle.
109109+/// ### Responses
110110+/// - 200 OK
111111+/// ## Errors
112112+/// - If the handle is already in use.
113113+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
114114+/// - 401 Unauthorized
115115+/// ## Panics
116116+/// - If the handle is not valid.
80117async fn update_handle(
81118 user: AuthenticatedUser,
82119 State(skey): State<SigningKey>,
···133170 ),
134171 },
135172 )
136136- .await
137173 .context("failed to sign plc op")?;
138174139175 if !config.test {
···149185 let doc = tokio::fs::File::options()
150186 .read(true)
151187 .write(true)
152152- .open(config.plc.path.join(format!("{}.car", did_hash)))
188188+ .open(config.plc.path.join(format!("{did_hash}.car")))
153189 .await
154190 .context("failed to open did doc")?;
155191···188224}
189225190226#[rustfmt::skip]
227227+/// Identity endpoints (/xrpc/com.atproto.identity.*)
228228+/// ### Routes
229229+/// - AP /xrpc/com.atproto.identity.updateHandle -> [`update_handle`]
230230+/// - AP /xrpc/com.atproto.identity.requestPlcOperationSignature -> [`request_plc_operation_signature`]
231231+/// - AP /xrpc/com.atproto.identity.signPlcOperation -> [`sign_plc_operation`]
232232+/// - UG /xrpc/com.atproto.identity.resolveHandle -> [`resolve_handle`]
191233pub(super) fn routes() -> Router<AppState> {
192192- // AP /xrpc/com.atproto.identity.updateHandle
193193- // AP /xrpc/com.atproto.identity.requestPlcOperationSignature
194194- // AP /xrpc/com.atproto.identity.signPlcOperation
195195- // UG /xrpc/com.atproto.identity.resolveHandle
196234 Router::new()
197235 .route(concat!("/", identity::update_handle::NSID), post(update_handle))
198236 .route(concat!("/", identity::request_plc_operation_signature::NSID), post(request_plc_operation_signature))
+168-65
src/endpoints/repo.rs
···11+//! PDS repository endpoints /xrpc/com.atproto.repo.*)
12use std::{collections::HashSet, str::FromStr as _};
2334use anyhow::{Context as _, anyhow};
···3839/// SHA2-256 mulithash
3940const IPLD_MH_SHA2_256: u64 = 0x12;
40414242+/// Used in [`scan_blobs`] to identify a blob.
4143#[derive(Deserialize, Debug, Clone)]
4244struct BlobRef {
4545+ /// `BlobRef` link. Include `$` when serializing to JSON, since `$` isn't allowed in struct names.
4346 #[serde(rename = "$link")]
4447 link: String,
4548}
46494750#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
4851#[serde(rename_all = "camelCase")]
5252+/// Parameters for [`list_records`].
4953pub(super) struct ListRecordsParameters {
5054 ///The NSID of the record type.
5155 pub collection: Nsid,
5656+ /// The cursor to start from.
5257 #[serde(skip_serializing_if = "core::option::Option::is_none")]
5358 pub cursor: Option<String>,
5459 ///The number of records to return.
···108113 }
109114}
110115116116+/// Resolves DID to DID document. Does not bi-directionally verify handle.
117117+/// - GET /xrpc/com.atproto.repo.resolveDid
118118+/// ### Query Parameters
119119+/// - `did`: DID to resolve.
120120+/// ### Responses
121121+/// - 200 OK: {`did_doc`: `did_doc`}
122122+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `DidNotFound`, `DidDeactivated`]}
111123async fn resolve_did(
112124 db: &Db,
113125 identifier: &AtIdentifier,
···150162 Ok((did.to_owned(), handle.to_owned()))
151163}
152164165165+/// Used in [`apply_writes`] to scan for blobs in the JSON object and return their CIDs.
153166fn scan_blobs(unknown: &Unknown) -> anyhow::Result<Vec<Cid>> {
154167 // { "$type": "blob", "ref": { "$link": "bafyrei..." } }
155168···160173 ];
161174 while let Some(value) = stack.pop() {
162175 match value {
163163- serde_json::Value::Null => (),
164164- serde_json::Value::Bool(_) => (),
165165- serde_json::Value::Number(_) => (),
166166- serde_json::Value::String(_) => (),
176176+ serde_json::Value::Bool(_)
177177+ | serde_json::Value::Null
178178+ | serde_json::Value::Number(_)
179179+ | serde_json::Value::String(_) => (),
167180 serde_json::Value::Array(values) => stack.extend(values.into_iter()),
168181 serde_json::Value::Object(map) => {
169182 if let (Some(blob_type), Some(blob_ref)) = (map.get("$type"), map.get("ref")) {
···196209 }
197210 });
198211199199- let blob = scan_blobs(&json.try_into_unknown().unwrap()).unwrap();
212212+ let blob = scan_blobs(&json.try_into_unknown().expect("should be valid JSON"))
213213+ .expect("should be able to scan blobs");
200214 assert_eq!(
201215 blob,
202202- vec![Cid::from_str("bafkreifzxf2wa6dyakzbdaxkz2wkvfrv3hiuafhxewbn5wahcw6eh3hzji").unwrap()]
216216+ vec![
217217+ Cid::from_str("bafkreifzxf2wa6dyakzbdaxkz2wkvfrv3hiuafhxewbn5wahcw6eh3hzji")
218218+ .expect("should be valid CID")
219219+ ]
203220 );
204221}
205222206206-#[expect(clippy::large_stack_frames)]
223223+#[expect(clippy::too_many_lines)]
224224+/// Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.
225225+/// - POST /xrpc/com.atproto.repo.applyWrites
226226+/// ### Request Body
227227+/// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account).
228228+/// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.
229229+/// - `writes`: `object[]` // One of:
230230+/// - - com.atproto.repo.applyWrites.create
231231+/// - - com.atproto.repo.applyWrites.update
232232+/// - - com.atproto.repo.applyWrites.delete
233233+/// - `swap_commit`: `cid` // If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.
207234async fn apply_writes(
208235 user: AuthenticatedUser,
209236 State(skey): State<SigningKey>,
···257284 blobs.extend(
258285 new_blobs
259286 .into_iter()
260260- .map(|blob_cid| (key.to_owned(), blob_cid)),
287287+ .map(|blob_cid| (key.clone(), blob_cid)),
261288 );
262289 }
263290···296323 blobs.extend(
297324 new_blobs
298325 .into_iter()
299299- .map(|blod_cid| (key.to_owned(), blod_cid)),
326326+ .map(|blod_cid| (key.clone(), blod_cid)),
300327 );
301328 }
302329 ops.push(RepoOp::Create {
···322349 blobs.extend(
323350 new_blobs
324351 .into_iter()
325325- .map(|blod_cid| (key.to_owned(), blod_cid)),
352352+ .map(|blod_cid| (key.clone(), blod_cid)),
326353 );
327354 }
328355 ops.push(RepoOp::Update {
···445472 .await
446473 .context("failed to remove blob_ref")?;
447474 }
448448- _ => {}
475475+ &RepoOp::Create { .. } => {}
449476 }
450477 }
451478452452- // for (key, cid) in &blobs {
453479 for &mut (ref key, cid) in &mut blobs {
454480 let cid_str = cid.to_string();
455481···520546 ))
521547}
522548549549+/// Create a single new repository record. Requires auth, implemented by PDS.
550550+/// - POST /xrpc/com.atproto.repo.createRecord
551551+/// ### Request Body
552552+/// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account).
553553+/// - `collection`: `nsid` // The NSID of the record collection.
554554+/// - `rkey`: `string` // The record key. <= 512 characters.
555555+/// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.
556556+/// - `record`
557557+/// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID.
558558+/// ### Responses
559559+/// - 200 OK: {`cid`: `cid`, `uri`: `at-uri`, `commit`: {`cid`: `cid`, `rev`: `tid`}, `validation_status`: [`valid`, `unknown`]}
560560+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidSwap`]}
561561+/// - 401 Unauthorized
523562async fn create_record(
524563 user: AuthenticatedUser,
525564 State(skey): State<SigningKey>,
···536575 State(fhp),
537576 Json(
538577 repo::apply_writes::InputData {
539539- repo: input.repo.to_owned(),
540540- validate: input.validate.to_owned(),
541541- swap_commit: input.swap_commit.to_owned(),
578578+ repo: input.repo.clone(),
579579+ validate: input.validate,
580580+ swap_commit: input.swap_commit.clone(),
542581 writes: vec![repo::apply_writes::InputWritesItem::Create(Box::new(
543582 repo::apply_writes::CreateData {
544544- collection: input.collection.to_owned(),
545545- rkey: input.rkey.to_owned(),
546546- value: input.record.to_owned(),
583583+ collection: input.collection.clone(),
584584+ rkey: input.rkey.clone(),
585585+ value: input.record.clone(),
547586 }
548587 .into(),
549588 ))],
···557596 let create_result = if let repo::apply_writes::OutputResultsItem::CreateResult(create_result) =
558597 write_result
559598 .results
560560- .to_owned()
599599+ .clone()
561600 .and_then(|result| result.first().cloned())
562601 .context("unexpected output from apply_writes")?
563602 {
···569608570609 Ok(Json(
571610 repo::create_record::OutputData {
572572- cid: create_result.cid.to_owned(),
573573- commit: write_result.commit.to_owned(),
574574- uri: create_result.uri.to_owned(),
611611+ cid: create_result.cid.clone(),
612612+ commit: write_result.commit.clone(),
613613+ uri: create_result.uri.clone(),
575614 validation_status: Some("unknown".to_owned()),
576615 }
577616 .into(),
578617 ))
579618}
580619620620+/// Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.
621621+/// - POST /xrpc/com.atproto.repo.putRecord
622622+/// ### Request Body
623623+/// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account).
624624+/// - `collection`: `nsid` // The NSID of the record collection.
625625+/// - `rkey`: `string` // The record key. <= 512 characters.
626626+/// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.
627627+/// - `record`
628628+/// - `swap_record`: `boolean` // Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation
629629+/// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID.
630630+/// ### Responses
631631+/// - 200 OK: {"uri": "string","cid": "string","commit": {"cid": "string","rev": "string"},"validationStatus": "valid | unknown"}
632632+/// - 400 Bad Request: {error:"`InvalidRequest` | `ExpiredToken` | `InvalidToken` | `InvalidSwap`"}
633633+/// - 401 Unauthorized
581634async fn put_record(
582635 user: AuthenticatedUser,
583636 State(skey): State<SigningKey>,
···596649 State(fhp),
597650 Json(
598651 repo::apply_writes::InputData {
599599- repo: input.repo.to_owned(),
652652+ repo: input.repo.clone(),
600653 validate: input.validate,
601601- swap_commit: input.swap_commit.to_owned(),
654654+ swap_commit: input.swap_commit.clone(),
602655 writes: vec![repo::apply_writes::InputWritesItem::Update(Box::new(
603656 repo::apply_writes::UpdateData {
604604- collection: input.collection.to_owned(),
605605- rkey: input.rkey.to_owned(),
606606- value: input.record.to_owned(),
657657+ collection: input.collection.clone(),
658658+ rkey: input.rkey.clone(),
659659+ value: input.record.clone(),
607660 }
608661 .into(),
609662 ))],
···616669617670 let update_result = write_result
618671 .results
619619- .to_owned()
672672+ .clone()
620673 .and_then(|result| result.first().cloned())
621674 .context("unexpected output from apply_writes")?;
622675 let (cid, uri) = match update_result {
623676 repo::apply_writes::OutputResultsItem::CreateResult(create_result) => (
624624- Some(create_result.cid.to_owned()),
625625- Some(create_result.uri.to_owned()),
677677+ Some(create_result.cid.clone()),
678678+ Some(create_result.uri.clone()),
626679 ),
627680 repo::apply_writes::OutputResultsItem::UpdateResult(update_result) => (
628628- Some(update_result.cid.to_owned()),
629629- Some(update_result.uri.to_owned()),
681681+ Some(update_result.cid.clone()),
682682+ Some(update_result.uri.clone()),
630683 ),
631631- _ => (None, None),
684684+ repo::apply_writes::OutputResultsItem::DeleteResult(_) => (None, None),
632685 };
633686 Ok(Json(
634687 repo::put_record::OutputData {
635688 cid: cid.context("missing cid")?,
636636- commit: write_result.commit.to_owned(),
689689+ commit: write_result.commit.clone(),
637690 uri: uri.context("missing uri")?,
638691 validation_status: Some("unknown".to_owned()),
639692 }
···641694 ))
642695}
643696697697+/// Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.
698698+/// - POST /xrpc/com.atproto.repo.deleteRecord
699699+/// ### Request Body
700700+/// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account).
701701+/// - `collection`: `nsid` // The NSID of the record collection.
702702+/// - `rkey`: `string` // The record key. <= 512 characters.
703703+/// - `swap_record`: `boolean` // Compare and swap with the previous record by CID.
704704+/// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID.
705705+/// ### Responses
706706+/// - 200 OK: {"commit": {"cid": "string","rev": "string"}}
707707+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidSwap`]}
708708+/// - 401 Unauthorized
644709async fn delete_record(
645710 user: AuthenticatedUser,
646711 State(skey): State<SigningKey>,
···661726 State(fhp),
662727 Json(
663728 repo::apply_writes::InputData {
664664- repo: input.repo.to_owned(),
665665- swap_commit: input.swap_commit.to_owned(),
729729+ repo: input.repo.clone(),
730730+ swap_commit: input.swap_commit.clone(),
666731 validate: None,
667732 writes: vec![repo::apply_writes::InputWritesItem::Delete(Box::new(
668733 repo::apply_writes::DeleteData {
669669- collection: input.collection.to_owned(),
670670- rkey: input.rkey.to_owned(),
734734+ collection: input.collection.clone(),
735735+ rkey: input.rkey.clone(),
671736 }
672737 .into(),
673738 ))],
···678743 .await
679744 .context("failed to apply writes")?
680745 .commit
681681- .to_owned(),
746746+ .clone(),
682747 }
683748 .into(),
684749 ))
685750}
686751752752+/// Get information about an account and repository, including the list of collections. Does not require auth.
753753+/// - GET /xrpc/com.atproto.repo.describeRepo
754754+/// ### Query Parameters
755755+/// - `repo`: `at-identifier` // The handle or DID of the repo.
756756+/// ### Responses
757757+/// - 200 OK: {"handle": "string","did": "string","didDoc": {},"collections": [string],"handleIsCorrect": true} \
758758+/// handeIsCorrect - boolean - Indicates if handle is currently valid (resolves bi-directionally)
759759+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
760760+/// - 401 Unauthorized
687761async fn describe_repo(
688762 State(config): State<AppConfig>,
689763 State(db): State<Db>,
···723797 ))
724798}
725799800800+/// Get a single record from a repository. Does not require auth.
801801+/// - GET /xrpc/com.atproto.repo.getRecord
802802+/// ### Query Parameters
803803+/// - `repo`: `at-identifier` // The handle or DID of the repo.
804804+/// - `collection`: `nsid` // The NSID of the record collection.
805805+/// - `rkey`: `string` // The record key. <= 512 characters.
806806+/// - `cid`: `cid` // The CID of the version of the record. If not specified, then return the most recent version.
807807+/// ### Responses
808808+/// - 200 OK: {"uri": "string","cid": "string","value": {}}
809809+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RecordNotFound`]}
810810+/// - 401 Unauthorized
726811async fn get_record(
727812 State(config): State<AppConfig>,
728813 State(db): State<Db>,
···760845 Err(Error::with_message(
761846 StatusCode::BAD_REQUEST,
762847 anyhow!("could not find the requested record at {}", uri),
763763- ErrorMessage::new(
764764- "RecordNotFound",
765765- format!("Could not locate record: {}", uri),
766766- ),
848848+ ErrorMessage::new("RecordNotFound", format!("Could not locate record: {uri}")),
767849 ))
768850 },
769851 |record_value| {
770852 Ok(Json(
771853 repo::get_record::OutputData {
772854 cid: cid.map(atrium_api::types::string::Cid::new),
773773- uri: uri.to_owned(),
855855+ uri: uri.clone(),
774856 value: record_value
775857 .try_into_unknown()
776858 .context("should be valid JSON")?,
···781863 )
782864}
783865866866+/// List a range of records in a repository, matching a specific collection. Does not require auth.
867867+/// - GET /xrpc/com.atproto.repo.listRecords
868868+/// ### Query Parameters
869869+/// - `repo`: `at-identifier` // The handle or DID of the repo.
870870+/// - `collection`: `nsid` // The NSID of the record type.
871871+/// - `limit`: `integer` // The maximum number of records to return. Default 50, >=1 and <=100.
872872+/// - `cursor`: `string`
873873+/// - `reverse`: `boolean` // Flag to reverse the order of the returned records.
874874+/// ### Responses
875875+/// - 200 OK: {"cursor": "string","records": [{"uri": "string","cid": "string","value": {}}]}
876876+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
877877+/// - 401 Unauthorized
784878async fn list_records(
785879 State(config): State<AppConfig>,
786880 State(db): State<Db>,
···830924 value: value.try_into_unknown().context("should be valid JSON")?,
831925 }
832926 .into(),
833833- )
927927+ );
834928 }
835929836930 #[expect(clippy::pattern_type_mismatch)]
···843937 ))
844938}
845939940940+/// Upload a new blob, to be referenced from a repository record. \
941941+/// The blob will be deleted if it is not referenced within a time window (eg, minutes). \
942942+/// Blob restrictions (mimetype, size, etc) are enforced when the reference is created. \
943943+/// Requires auth, implemented by PDS.
944944+/// - POST /xrpc/com.atproto.repo.uploadBlob
945945+/// ### Request Body
946946+/// ### Responses
947947+/// - 200 OK: {"blob": "binary"}
948948+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
949949+/// - 401 Unauthorized
846950async fn upload_blob(
847951 user: AuthenticatedUser,
848952 State(config): State<AppConfig>,
···91910239201024 let cid_str = cid.to_string();
9211025922922- tokio::fs::rename(
923923- &filename,
924924- config.blob.path.join(format!("{}.blob", cid_str)),
925925- )
926926- .await
927927- .context("failed to finalize blob")?;
10261026+ tokio::fs::rename(&filename, config.blob.path.join(format!("{cid_str}.blob")))
10271027+ .await
10281028+ .context("failed to finalize blob")?;
92810299291030 let did_str = user.did();
9301031···9511052 ))
9521053}
9531054954954-#[rustfmt::skip]
10551055+/// These endpoints are part of the atproto PDS repository management APIs. \
10561056+/// Requests usually require authentication (unlike the com.atproto.sync.* endpoints), and are made directly to the user's own PDS instance.
10571057+/// ### Routes
10581058+/// - AP /xrpc/com.atproto.repo.applyWrites -> [`apply_writes`]
10591059+/// - AP /xrpc/com.atproto.repo.createRecord -> [`create_record`]
10601060+/// - AP /xrpc/com.atproto.repo.putRecord -> [`put_record`]
10611061+/// - AP /xrpc/com.atproto.repo.deleteRecord -> [`delete_record`]
10621062+/// - AP /xrpc/com.atproto.repo.uploadBlob -> [`upload_blob`]
10631063+/// - UG /xrpc/com.atproto.repo.describeRepo -> [`describe_repo`]
10641064+/// - UG /xrpc/com.atproto.repo.getRecord -> [`get_record`]
10651065+/// - UG /xrpc/com.atproto.repo.listRecords -> [`list_records`]
9551066pub(super) fn routes() -> Router<AppState> {
956956- // AP /xrpc/com.atproto.repo.applyWrites
957957- // AP /xrpc/com.atproto.repo.createRecord
958958- // AP /xrpc/com.atproto.repo.putRecord
959959- // AP /xrpc/com.atproto.repo.deleteRecord
960960- // AP /xrpc/com.atproto.repo.uploadBlob
961961- // UG /xrpc/com.atproto.repo.describeRepo
962962- // UG /xrpc/com.atproto.repo.getRecord
963963- // UG /xrpc/com.atproto.repo.listRecords
9641067 Router::new()
965965- .route(concat!("/", repo::apply_writes::NSID), post(apply_writes))
10681068+ .route(concat!("/", repo::apply_writes::NSID), post(apply_writes))
9661069 .route(concat!("/", repo::create_record::NSID), post(create_record))
967967- .route(concat!("/", repo::put_record::NSID), post(put_record))
10701070+ .route(concat!("/", repo::put_record::NSID), post(put_record))
9681071 .route(concat!("/", repo::delete_record::NSID), post(delete_record))
969969- .route(concat!("/", repo::upload_blob::NSID), post(upload_blob))
10721072+ .route(concat!("/", repo::upload_blob::NSID), post(upload_blob))
9701073 .route(concat!("/", repo::describe_repo::NSID), get(describe_repo))
971971- .route(concat!("/", repo::get_record::NSID), get(get_record))
972972- .route(concat!("/", repo::list_records::NSID), get(list_records))
10741074+ .route(concat!("/", repo::get_record::NSID), get(get_record))
10751075+ .route(concat!("/", repo::list_records::NSID), get(list_records))
9731076}
+105-38
src/endpoints/server.rs
···11+//! Server endpoints. (/xrpc/com.atproto.server.*)
12use std::{collections::HashMap, str::FromStr as _};
2334use anyhow::{Context as _, anyhow};
···3738/// This is a dummy password that can be used in absence of a real password.
3839const DUMMY_PASSWORD: &str = "$argon2id$v=19$m=19456,t=2,p=1$En2LAfHjeO0SZD5IUU1Abg$RpS8nHhhqY4qco2uyd41p9Y/1C+Lvi214MAWukzKQMI";
39404141+/// Create an invite code.
4242+/// - POST /xrpc/com.atproto.server.createInviteCode
4343+/// ### Request Body
4444+/// - `useCount`: integer
4545+/// - `forAccount`: string (optional)
4646+/// ### Responses
4747+/// - 200 OK: {code: string}
4848+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
4949+/// - 401 Unauthorized
4050async fn create_invite_code(
4151 _user: AuthenticatedUser,
4252 State(db): State<Db>,
···7080 ))
7181}
72828383+#[expect(clippy::too_many_lines, reason = "TODO: refactor")]
8484+/// Create an account. Implemented by PDS.
8585+/// - POST /xrpc/com.atproto.server.createAccount
8686+/// ### Request Body
8787+/// - `email`: string
8888+/// - `handle`: string (required)
8989+/// - `did`: string - Pre-existing atproto DID, being imported to a new account.
9090+/// - `inviteCode`: string
9191+/// - `verificationCode`: string
9292+/// - `verificationPhone`: string
9393+/// - `password`: string - Initial account password. May need to meet instance-specific password strength requirements.
9494+/// - `recoveryKey`: string - DID PLC rotation key (aka, recovery key) to be included in PLC creation operation.
9595+/// - `plcOp`: object
9696+/// ## Responses
9797+/// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {}}
9898+/// - 400 Bad Request: {error: [`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidHandle`, `InvalidPassword`, \
9999+/// `InvalidInviteCode`, `HandleNotAvailable`, `UnsupportedDomain`, `UnresolvableDid`, `IncompatibleDidDoc`)}
100100+/// - 401 Unauthorized
73101async fn create_account(
74102 State(db): State<Db>,
75103 State(skey): State<SigningKey>,
···164192 prev: None,
165193 },
166194 )
167167- .await
168195 .context("failed to sign genesis op")?;
169196 let op_bytes = serde_ipld_dagcbor::to_vec(&op).context("failed to encode genesis op")?;
170197···179206 #[expect(clippy::string_slice, reason = "digest length confirmed")]
180207 digest[..24].to_owned()
181208 };
182182- let did = format!("did:plc:{}", did_hash);
209209+ let did = format!("did:plc:{did_hash}");
183210184184- let doc = tokio::fs::File::create(config.plc.path.join(format!("{}.car", did_hash)))
211211+ let doc = tokio::fs::File::create(config.plc.path.join(format!("{did_hash}.car")))
185212 .await
186213 .context("failed to create did doc")?;
187214···205232 // Write out an initial commit for the user.
206233 // https://atproto.com/guides/account-lifecycle
207234 let (cid, rev, store) = async {
208208- let file = tokio::fs::File::create_new(config.repo.path.join(format!("{}.car", did_hash)))
235235+ let file = tokio::fs::File::create_new(config.repo.path.join(format!("{did_hash}.car")))
209236 .await
210237 .context("failed to create repo file")?;
211238 let mut store = CarStore::create(file)
···316343 let token = auth::sign(
317344 &skey,
318345 "at+jwt",
319319- serde_json::json!({
346346+ &serde_json::json!({
320347 "scope": "com.atproto.access",
321348 "sub": did,
322349 "iat": chrono::Utc::now().timestamp(),
···329356 let refresh_token = auth::sign(
330357 &skey,
331358 "refresh+jwt",
332332- serde_json::json!({
359359+ &serde_json::json!({
333360 "scope": "com.atproto.refresh",
334361 "sub": did,
335362 "iat": chrono::Utc::now().timestamp(),
···351378 ))
352379}
353380381381+/// Create an authentication session.
382382+/// - POST /xrpc/com.atproto.server.createSession
383383+/// ### Request Body
384384+/// - `identifier`: string - Handle or other identifier supported by the server for the authenticating user.
385385+/// - `password`: string - Password for the authenticating user.
386386+/// - `authFactorToken` - string (optional)
387387+/// - `allowTakedown` - boolean (optional) - When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned
388388+/// ### Responses
389389+/// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {},"email": "string","emailConfirmed": true,"emailAuthFactor": true,"active": true,"status": "takendown"}
390390+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `AccountTakedown`, `AuthFactorTokenRequired`]}
391391+/// - 401 Unauthorized
354392async fn create_session(
355393 State(db): State<Db>,
356394 State(skey): State<SigningKey>,
···363401 // TODO: `input.allow_takedown`
364402 // TODO: `input.auth_factor_token`
365403366366- let account = if let Some(account) = sqlx::query!(
404404+ let Some(account) = sqlx::query!(
367405 r#"
368368- WITH LatestHandles AS (
369369- SELECT did, handle
370370- FROM handles
371371- WHERE (did, created_at) IN (
372372- SELECT did, MAX(created_at) AS max_created_at
373373- FROM handles
374374- GROUP BY did
375375- )
376376- )
377377- SELECT a.did, a.password, h.handle
378378- FROM accounts a
379379- LEFT JOIN LatestHandles h ON a.did = h.did
380380- WHERE h.handle = ?
381381- "#,
406406+ WITH LatestHandles AS (
407407+ SELECT did, handle
408408+ FROM handles
409409+ WHERE (did, created_at) IN (
410410+ SELECT did, MAX(created_at) AS max_created_at
411411+ FROM handles
412412+ GROUP BY did
413413+ )
414414+ )
415415+ SELECT a.did, a.password, h.handle
416416+ FROM accounts a
417417+ LEFT JOIN LatestHandles h ON a.did = h.did
418418+ WHERE h.handle = ?
419419+ "#,
382420 handle
383421 )
384422 .fetch_optional(&db)
385423 .await
386424 .context("failed to authenticate")?
387387- {
388388- account
389389- } else {
425425+ else {
390426 counter!(AUTH_FAILED).increment(1);
391427392428 // SEC: Call argon2's `verify_password` to simulate password verification and discard the result.
···407443 password.as_bytes(),
408444 &PasswordHash::new(account.password.as_str()).context("invalid password hash in db")?,
409445 ) {
410410- Ok(_) => {}
446446+ Ok(()) => {}
411447 Err(_e) => {
412448 counter!(AUTH_FAILED).increment(1);
413449···423459 let token = auth::sign(
424460 &skey,
425461 "at+jwt",
426426- serde_json::json!({
462462+ &serde_json::json!({
427463 "scope": "com.atproto.access",
428464 "sub": did,
429465 "iat": chrono::Utc::now().timestamp(),
···436472 let refresh_token = auth::sign(
437473 &skey,
438474 "refresh+jwt",
439439- serde_json::json!({
475475+ &serde_json::json!({
440476 "scope": "com.atproto.refresh",
441477 "sub": did,
442478 "iat": chrono::Utc::now().timestamp(),
···464500 ))
465501}
466502503503+/// Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt').
504504+/// - POST /xrpc/com.atproto.server.refreshSession
505505+/// ### Responses
506506+/// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {},"active": true,"status": "takendown"}
507507+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `AccountTakedown`]}
508508+/// - 401 Unauthorized
467509async fn refresh_session(
468510 State(db): State<Db>,
469511 State(skey): State<SigningKey>,
···490532 }
491533 if claims
492534 .get("exp")
493493- .and_then(|exp| exp.as_i64())
535535+ .and_then(serde_json::Value::as_i64)
494536 .context("failed to get `exp`")?
495537 < chrono::Utc::now().timestamp()
496538 {
···534576 let token = auth::sign(
535577 &skey,
536578 "at+jwt",
537537- serde_json::json!({
579579+ &serde_json::json!({
538580 "scope": "com.atproto.access",
539581 "sub": did,
540582 "iat": chrono::Utc::now().timestamp(),
···547589 let refresh_token = auth::sign(
548590 &skey,
549591 "refresh+jwt",
550550- serde_json::json!({
592592+ &serde_json::json!({
551593 "scope": "com.atproto.refresh",
552594 "sub": did,
553595 "iat": chrono::Utc::now().timestamp(),
···575617 ))
576618}
577619620620+/// Get a signed token on behalf of the requesting DID for the requested service.
621621+/// - GET /xrpc/com.atproto.server.getServiceAuth
622622+/// ### Request Query Parameters
623623+/// - `aud`: string - The DID of the service that the token will be used to authenticate with
624624+/// - `exp`: integer (optional) - The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.
625625+/// - `lxm`: string (optional) - Lexicon (XRPC) method to bind the requested token to
626626+/// ### Responses
627627+/// - 200 OK: {token: string}
628628+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `BadExpiration`]}
629629+/// - 401 Unauthorized
578630async fn get_service_auth(
579631 user: AuthenticatedUser,
580632 State(skey): State<SigningKey>,
···608660 }
609661610662 // Mint a bearer token by signing a JSON web token.
611611- let token = auth::sign(&skey, "JWT", claims).context("failed to sign jwt")?;
663663+ let token = auth::sign(&skey, "JWT", &claims).context("failed to sign jwt")?;
612664613665 Ok(Json(server::get_service_auth::OutputData { token }.into()))
614666}
615667668668+/// Get information about the current auth session. Requires auth.
669669+/// - GET /xrpc/com.atproto.server.getSession
670670+/// ### Responses
671671+/// - 200 OK: {"handle": "string","did": "string","email": "string","emailConfirmed": true,"emailAuthFactor": true,"didDoc": {},"active": true,"status": "takendown"}
672672+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
673673+/// - 401 Unauthorized
616674async fn get_session(
617675 user: AuthenticatedUser,
618676 State(db): State<Db>,
···661719 }
662720}
663721722722+/// Describes the server's account creation requirements and capabilities. Implemented by PDS.
723723+/// - GET /xrpc/com.atproto.server.describeServer
724724+/// ### Responses
725725+/// - 200 OK: {"inviteCodeRequired": true,"phoneVerificationRequired": true,"availableUserDomains": [`string`],"links": {"privacyPolicy": "string","termsOfService": "string"},"contact": {"email": "string"},"did": "string"}
726726+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
727727+/// - 401 Unauthorized
664728async fn describe_server(
665729 State(config): State<AppConfig>,
666730) -> Result<Json<server::describe_server::Output>> {
···679743}
680744681745#[rustfmt::skip]
746746+/// These endpoints are part of the atproto PDS server and account management APIs. \
747747+/// Requests often require authentication and are made directly to the user's own PDS instance.
748748+/// ### Routes
749749+/// - `GET /xrpc/com.atproto.server.describeServer` -> [`describe_server`]
750750+/// - `POST /xrpc/com.atproto.server.createAccount` -> [`create_account`]
751751+/// - `POST /xrpc/com.atproto.server.createSession` -> [`create_session`]
752752+/// - `POST /xrpc/com.atproto.server.refreshSession` -> [`refresh_session`]
753753+/// - `GET /xrpc/com.atproto.server.getServiceAuth` -> [`get_service_auth`]
754754+/// - `GET /xrpc/com.atproto.server.getSession` -> [`get_session`]
755755+/// - `POST /xrpc/com.atproto.server.createInviteCode` -> [`create_invite_code`]
682756pub(super) fn routes() -> Router<AppState> {
683683- // UG /xrpc/com.atproto.server.describeServer
684684- // UP /xrpc/com.atproto.server.createAccount
685685- // UP /xrpc/com.atproto.server.createSession
686686- // AP /xrpc/com.atproto.server.refreshSession
687687- // AG /xrpc/com.atproto.server.getServiceAuth
688688- // AG /xrpc/com.atproto.server.getSession
689689- // AP /xrpc/com.atproto.server.createInviteCode
690757 Router::new()
691758 .route(concat!("/", server::describe_server::NSID), get(describe_server))
692759 .route(concat!("/", server::create_account::NSID), post(create_account))
+92-16
src/endpoints/sync.rs
···11+//! Endpoints for the `ATProto` sync API. (/xrpc/com.atproto.sync.*)
12use std::str::FromStr as _;
2334use anyhow::{Context as _, anyhow};
···2728 storage::{open_repo_db, open_store},
2829};
29303030-// HACK: `limit` may be passed as a string, so we must treat it as one.
3131#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
3232#[serde(rename_all = "camelCase")]
3333+/// Parameters for `/xrpc/com.atproto.sync.listBlobs` \
3434+/// HACK: `limit` may be passed as a string, so we must treat it as one.
3335pub(super) struct ListBlobsParameters {
3436 #[serde(skip_serializing_if = "core::option::Option::is_none")]
3737+ /// Optional cursor to paginate through blobs.
3538 pub cursor: Option<String>,
3639 ///The DID of the repo.
3740 pub did: Did,
3841 #[serde(skip_serializing_if = "core::option::Option::is_none")]
4242+ /// Optional limit of blobs to return.
3943 pub limit: Option<String>,
4044 ///Optional revision of the repo to list blobs since.
4145 #[serde(skip_serializing_if = "core::option::Option::is_none")]
4246 pub since: Option<String>,
4347}
4444-// HACK: `limit` may be passed as a string, so we must treat it as one.
4548#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
4649#[serde(rename_all = "camelCase")]
5050+/// Parameters for `/xrpc/com.atproto.sync.listRepos` \
5151+/// HACK: `limit` may be passed as a string, so we must treat it as one.
4752pub(super) struct ListReposParameters {
4853 #[serde(skip_serializing_if = "core::option::Option::is_none")]
5454+ /// Optional cursor to paginate through repos.
4955 pub cursor: Option<String>,
5056 #[serde(skip_serializing_if = "core::option::Option::is_none")]
5757+ /// Optional limit of repos to return.
5158 pub limit: Option<String>,
5259}
5353-// HACK: `cursor` may be passed as a string, so we must treat it as one.
5460#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
5561#[serde(rename_all = "camelCase")]
6262+/// Parameters for `/xrpc/com.atproto.sync.subscribeRepos` \
6363+/// HACK: `cursor` may be passed as a string, so we must treat it as one.
5664pub(super) struct SubscribeReposParametersData {
5765 ///The last known event seq number to backfill from.
5866 #[serde(skip_serializing_if = "core::option::Option::is_none")]
···8088 let s = ReaderStream::new(f);
81898290 Ok(Response::builder()
8383- .header(http::header::CONTENT_LENGTH, format!("{}", len))
9191+ .header(http::header::CONTENT_LENGTH, format!("{len}"))
8492 .body(Body::from_stream(s))
8593 .context("failed to construct response")?)
8694}
87959696+/// Enumerates which accounts the requesting account is currently blocking. Requires auth.
9797+/// - GET /xrpc/com.atproto.sync.getBlocks
9898+/// ### Query Parameters
9999+/// - `limit`: integer, optional, default: 50, >=1 and <=100
100100+/// - `cursor`: string, optional
101101+/// ### Responses
102102+/// - 200 OK: ...
103103+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
104104+/// - 401 Unauthorized
88105async fn get_blocks(
89106 State(config): State<AppConfig>,
90107 Query(input): Query<sync::get_blocks::Parameters>,
···120137 .context("failed to construct response")?)
121138}
122139140140+/// Get the current commit CID & revision of the specified repo. Does not require auth.
141141+/// ### Query Parameters
142142+/// - `did`: The DID of the repo.
143143+/// ### Responses
144144+/// - 200 OK: {"cid": "string","rev": "string"}
145145+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoTakendown`, `RepoSuspended`, `RepoDeactivated`]}
123146async fn get_latest_commit(
124147 State(config): State<AppConfig>,
125148 State(db): State<Db>,
···141164 ))
142165}
143166167167+/// Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.
168168+/// ### Query Parameters
169169+/// - `did`: The DID of the repo.
170170+/// - `collection`: nsid
171171+/// - `rkey`: record-key
172172+/// ### Responses
173173+/// - 200 OK: ...
174174+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RecordNotFound`, `RepoNotFound`, `RepoTakendown`,
175175+/// `RepoSuspended`, `RepoDeactivated`]}
144176async fn get_record(
145177 State(config): State<AppConfig>,
146178 State(db): State<Db>,
···168200 .context("failed to construct response")?)
169201}
170202203203+/// Get the hosting status for a repository, on this server. Expected to be implemented by PDS and Relay.
204204+/// ### Query Parameters
205205+/// - `did`: The DID of the repo.
206206+/// ### Responses
207207+/// - 200 OK: {"did": "string","active": true,"status": "takendown","rev": "string"}
208208+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`]}
171209async fn get_repo_status(
172210 State(db): State<Db>,
173211 Query(input): Query<sync::get_repo::Parameters>,
···178216 .await
179217 .context("failed to execute query")?;
180218181181- let r = if let Some(r) = r {
182182- r
183183- } else {
219219+ let Some(r) = r else {
184220 return Err(Error::with_status(
185221 StatusCode::NOT_FOUND,
186222 anyhow!("account not found"),
···201237 ))
202238}
203239240240+/// Download a repository export as CAR file. Optionally only a 'diff' since a previous revision.
241241+/// Does not require auth; implemented by PDS.
242242+/// ### Query Parameters
243243+/// - `did`: The DID of the repo.
244244+/// - `since`: The revision ('rev') of the repo to create a diff from.
245245+/// ### Responses
246246+/// - 200 OK: ...
247247+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`,
248248+/// `RepoTakendown`, `RepoSuspended`, `RepoDeactivated`]}
204249async fn get_repo(
205250 State(config): State<AppConfig>,
206251 State(db): State<Db>,
···225270 .context("failed to construct response")?)
226271}
227272273273+/// List blob CIDs for an account, since some repo revision. Does not require auth; implemented by PDS.
274274+/// ### Query Parameters
275275+/// - `did`: The DID of the repo. Required.
276276+/// - `since`: Optional revision of the repo to list blobs since.
277277+/// - `limit`: >= 1 and <= 1000, default 500
278278+/// - `cursor`: string
279279+/// ### Responses
280280+/// - 200 OK: {"cursor": "string","cids": [string]}
281281+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`, `RepoTakendown`,
282282+/// `RepoSuspended`, `RepoDeactivated`]}
228283async fn list_blobs(
229284 State(db): State<Db>,
230285 Query(input): Query<ListBlobsParameters>,
···255310 ))
256311}
257312313313+/// Enumerates all the DID, rev, and commit CID for all repos hosted by this service.
314314+/// Does not require auth; implemented by PDS and Relay.
315315+/// ### Query Parameters
316316+/// - `limit`: >= 1 and <= 1000, default 500
317317+/// - `cursor`: string
318318+/// ### Responses
319319+/// - 200 OK: {"cursor": "string","repos": [{"did": "string","head": "string","rev": "string","active": true,"status": "takendown"}]}
320320+/// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]}
258321async fn list_repos(
259322 State(db): State<Db>,
260323 Query(input): Query<ListReposParameters>,
261324) -> Result<Json<sync::list_repos::Output>> {
262325 struct Record {
326326+ /// The DID of the repo.
263327 did: String,
328328+ /// The commit CID of the repo.
264329 rev: String,
330330+ /// The root CID of the repo.
265331 root: String,
266332 }
267333···317383 Ok(Json(sync::list_repos::OutputData { cursor, repos }.into()))
318384}
319385386386+/// Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events,
387387+/// for all repositories on the current server. See the atproto specifications for details around stream sequencing,
388388+/// repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay.
389389+/// ### Query Parameters
390390+/// - `cursor`: The last known event seq number to backfill from.
391391+/// ### Responses
392392+/// - 200 OK: ...
320393async fn subscribe_repos(
321394 ws_up: WebSocketUpgrade,
322395 State(fh): State<FirehoseProducer>,
···337410}
338411339412#[rustfmt::skip]
413413+/// These endpoints are part of the atproto repository synchronization APIs. Requests usually do not require authentication,
414414+/// and can be made to PDS intances or Relay instances.
415415+/// ### Routes
416416+/// - `GET /xrpc/com.atproto.sync.getBlob` -> [`get_blob`]
417417+/// - `GET /xrpc/com.atproto.sync.getBlocks` -> [`get_blocks`]
418418+/// - `GET /xrpc/com.atproto.sync.getLatestCommit` -> [`get_latest_commit`]
419419+/// - `GET /xrpc/com.atproto.sync.getRecord` -> [`get_record`]
420420+/// - `GET /xrpc/com.atproto.sync.getRepoStatus` -> [`get_repo_status`]
421421+/// - `GET /xrpc/com.atproto.sync.getRepo` -> [`get_repo`]
422422+/// - `GET /xrpc/com.atproto.sync.listBlobs` -> [`list_blobs`]
423423+/// - `GET /xrpc/com.atproto.sync.listRepos` -> [`list_repos`]
424424+/// - `GET /xrpc/com.atproto.sync.subscribeRepos` -> [`subscribe_repos`]
340425pub(super) fn routes() -> Router<AppState> {
341341- // UG /xrpc/com.atproto.sync.getBlob
342342- // UG /xrpc/com.atproto.sync.getBlocks
343343- // UG /xrpc/com.atproto.sync.getLatestCommit
344344- // UG /xrpc/com.atproto.sync.getRecord
345345- // UG /xrpc/com.atproto.sync.getRepoStatus
346346- // UG /xrpc/com.atproto.sync.getRepo
347347- // UG /xrpc/com.atproto.sync.listBlobs
348348- // UG /xrpc/com.atproto.sync.listRepos
349349- // UG /xrpc/com.atproto.sync.subscribeRepos
350426 Router::new()
351427 .route(concat!("/", sync::get_blob::NSID), get(get_blob))
352428 .route(concat!("/", sync::get_blocks::NSID), get(get_blocks))
+7-1
src/error.rs
···1111#[derive(Error)]
1212#[expect(clippy::error_impl_error, reason = "just one")]
1313pub struct Error {
1414+ /// The actual error that occurred.
1415 err: anyhow::Error,
1616+ /// The error message to be returned as JSON body.
1517 message: Option<ErrorMessage>,
1818+ /// The HTTP status code to be returned.
1619 status: StatusCode,
1720}
18211922#[derive(Default, serde::Serialize)]
2023/// A JSON error message.
2124pub(crate) struct ErrorMessage {
2525+ /// The error type.
2626+ /// This is used to identify the error in the client.
2727+ /// E.g. `InvalidRequest`, `ExpiredToken`, `InvalidToken`, `HandleNotFound`.
2228 error: String,
2929+ /// The error message.
2330 message: String,
2431}
2532impl std::fmt::Display for ErrorMessage {
···9198}
929993100impl IntoResponse for Error {
9494- #[expect(clippy::cognitive_complexity)]
95101 fn into_response(self) -> Response {
96102 error!("{:?}", self.err);
97103
+42-54
src/firehose.rs
···145145/// A firehose producer. This is used to transmit messages to the firehose for broadcast.
146146#[derive(Clone, Debug)]
147147pub(crate) struct FirehoseProducer {
148148+ /// The channel to send messages to the firehose.
148149 tx: tokio::sync::mpsc::Sender<FirehoseMessage>,
149150}
150151···189190 }
190191}
191192192192-#[expect(clippy::as_conversions)]
193193+#[expect(
194194+ clippy::as_conversions,
195195+ clippy::cast_possible_truncation,
196196+ clippy::cast_sign_loss,
197197+ clippy::cast_precision_loss,
198198+ clippy::arithmetic_side_effects
199199+)]
200200+/// Convert a `usize` to a `f64`.
193201const fn convert_usize_f64(x: usize) -> Result<f64, &'static str> {
194202 let result = x as f64;
195195- if result as usize != x {
203203+ if result as usize - x > 0 {
196204 return Err("cannot convert");
197205 }
198206 Ok(result)
199207}
200208201209/// Serialize a message.
202202-async fn serialize_message(
203203- seq: u64,
204204- mut msg: sync::subscribe_repos::Message,
205205-) -> (&'static str, Vec<u8>) {
210210+fn serialize_message(seq: u64, mut msg: sync::subscribe_repos::Message) -> (&'static str, Vec<u8>) {
206211 let mut dummy_seq = 0_i64;
207212 #[expect(clippy::pattern_type_mismatch)]
208213 let (ty, nseq) = match &mut msg {
···214219 sync::subscribe_repos::Message::Migrate(m) => ("#migrate", &mut m.seq),
215220 sync::subscribe_repos::Message::Tombstone(m) => ("#tombstone", &mut m.seq),
216221 };
217217-218218- #[expect(clippy::as_conversions)]
219219- const fn convert_u64_i64(x: u64) -> Result<i64, &'static str> {
220220- let result = x as i64;
221221- if result as u64 != x {
222222- return Err("cannot convert");
223223- }
224224- Ok(result)
225225- }
226222 // Set the sequence number.
227227- *nseq = convert_u64_i64(seq).expect("should find seq");
223223+ *nseq = i64::try_from(seq).expect("should find seq");
228224229225 let hdr = FrameHeader::Message(ty.to_owned());
230226···261257) -> Result<WebSocket> {
262258 if let Some(cursor) = cursor {
263259 let mut frame = Vec::new();
264264- #[expect(clippy::as_conversions)]
265265- const fn convert_i64_u64(x: i64) -> Result<u64, &'static str> {
266266- let result = x as u64;
267267- if result as i64 != x {
268268- return Err("cannot convert");
269269- }
270270- Ok(result)
260260+ let cursor = u64::try_from(cursor);
261261+ if cursor.is_err() {
262262+ tracing::warn!("cursor is not a valid u64");
263263+ return Ok(ws);
271264 }
272272- let cursor = convert_i64_u64(cursor).expect("should find cursor");
273273-265265+ let cursor = cursor.expect("should be valid u64");
274266 // Cursor specified; attempt to backfill the consumer.
275267 if cursor > seq {
276268 let hdr = FrameHeader::Error;
···286278 );
287279 }
288280289289- for &(historical_seq, ty, ref msg) in history.iter() {
281281+ for &(historical_seq, ty, ref msg) in history {
290282 if cursor > historical_seq {
291283 continue;
292284 }
···314306315307 info!("attempting to reconnect to upstream relays");
316308 for relay in &config.firehose.relays {
317317- let host = match relay.host_str() {
318318- Some(host) => host,
319319- None => {
320320- warn!("relay {} has no host specified", relay);
321321- continue;
322322- }
309309+ let Some(host) = relay.host_str() else {
310310+ warn!("relay {} has no host specified", relay);
311311+ continue;
323312 };
324313325314 let r = client
···356345///
357346/// This will broadcast all updates in this PDS out to anyone who is listening.
358347///
359359-/// Reference: https://atproto.com/specs/sync
360360-pub(crate) async fn spawn(
348348+/// Reference: <https://atproto.com/specs/sync>
349349+pub(crate) fn spawn(
361350 client: Client,
362351 config: AppConfig,
363352) -> (tokio::task::JoinHandle<()>, FirehoseProducer) {
364353 let (tx, mut rx) = tokio::sync::mpsc::channel(1000);
365354 let handle = tokio::spawn(async move {
366366- let mut clients: Vec<WebSocket> = Vec::new();
367367- let mut history = VecDeque::with_capacity(1000);
368355 fn time_since_inception() -> u64 {
369356 chrono::Utc::now()
370357 .timestamp_micros()
···372359 .expect("should not wrap")
373360 .unsigned_abs()
374361 }
362362+ let mut clients: Vec<WebSocket> = Vec::new();
363363+ let mut history = VecDeque::with_capacity(1000);
375364 let mut seq = time_since_inception();
376365377366 // TODO: We should use `com.atproto.sync.notifyOfUpdate` to reach out to relays
378367 // that may have disconnected from us due to timeout.
379368380369 loop {
381381- match tokio::time::timeout(Duration::from_secs(30), rx.recv()).await {
382382- Ok(msg) => match msg {
370370+ if let Ok(msg) = tokio::time::timeout(Duration::from_secs(30), rx.recv()).await {
371371+ match msg {
383372 Some(FirehoseMessage::Broadcast(msg)) => {
384384- let (ty, by) = serialize_message(seq, msg.clone()).await;
373373+ let (ty, by) = serialize_message(seq, msg.clone());
385374386375 history.push_back((seq, ty, msg));
387376 gauge!(FIREHOSE_HISTORY).set(
···419408 }
420409 // All producers have been destroyed.
421410 None => break,
422422- },
423423- Err(_) => {
424424- if clients.is_empty() {
425425- reconnect_relays(&client, &config).await;
426426- }
411411+ }
412412+ } else {
413413+ if clients.is_empty() {
414414+ reconnect_relays(&client, &config).await;
415415+ }
427416428428- let contents = rand::thread_rng()
429429- .sample_iter(rand::distributions::Alphanumeric)
430430- .take(15)
431431- .map(char::from)
432432- .collect::<String>();
417417+ let contents = rand::thread_rng()
418418+ .sample_iter(rand::distributions::Alphanumeric)
419419+ .take(15)
420420+ .map(char::from)
421421+ .collect::<String>();
433422434434- // Send a websocket ping message.
435435- // Reference: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets
436436- let message = Message::Ping(axum::body::Bytes::from_owner(contents));
437437- drop(broadcast_message(&mut clients, message).await);
438438- }
423423+ // Send a websocket ping message.
424424+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets
425425+ let message = Message::Ping(axum::body::Bytes::from_owner(contents));
426426+ drop(broadcast_message(&mut clients, message).await);
439427 }
440428 }
441429 });
+42-46
src/main.rs
···6868 }
6969 }
70707171- #[rustfmt::skip]
7271 /// Register all actor endpoints.
7372 pub(crate) fn routes() -> Router<AppState> {
7473 // AP /xrpc/app.bsky.actor.putPreferences
7574 // AG /xrpc/app.bsky.actor.getPreferences
7675 Router::new()
7777- .route(concat!("/", actor::put_preferences::NSID), post(put_preferences))
7878- .route(concat!("/", actor::get_preferences::NSID), get(get_preferences))
7676+ .route(
7777+ concat!("/", actor::put_preferences::NSID),
7878+ post(put_preferences),
7979+ )
8080+ .route(
8181+ concat!("/", actor::get_preferences::NSID),
8282+ get(get_preferences),
8383+ )
7984 }
8085}
8186···202207203208/// The index (/) route.
204209async fn index() -> impl IntoResponse {
205205- r#"
210210+ r"
206211 __ __
207212 /\ \__ /\ \__
208213 __ \ \ ,_\ _____ _ __ ___\ \ ,_\ ___
···220225221226 Code: https://github.com/DrChat/bluepds
222227 Protocol: https://atproto.com
223223- "#
228228+ "
224229}
225230226231/// Service proxy.
227232///
228228-/// Reference: https://atproto.com/specs/xrpc#service-proxying
233233+/// Reference: <https://atproto.com/specs/xrpc#service-proxying>
229234async fn service_proxy(
230235 uri: Uri,
231236 user: AuthenticatedUser,
···265270 .await
266271 .with_context(|| format!("failed to resolve did document {}", did.as_str()))?;
267272268268- let service = match did_doc.service.iter().find(|s| s.id == id) {
269269- Some(service) => service,
270270- None => {
271271- return Err(Error::with_status(
272272- StatusCode::BAD_REQUEST,
273273- anyhow!("could not find resolve service #{id}"),
274274- ));
275275- }
273273+ let Some(service) = did_doc.service.iter().find(|s| s.id == id) else {
274274+ return Err(Error::with_status(
275275+ StatusCode::BAD_REQUEST,
276276+ anyhow!("could not find resolve service #{id}"),
277277+ ));
276278 };
277279278278- let url = service
280280+ let target_url: url::Url = service
279281 .service_endpoint
280280- .join(&format!("/xrpc{}", url_path))
282282+ .join(&format!("/xrpc{url_path}"))
281283 .context("failed to construct target url")?;
282284283285 let exp = (chrono::Utc::now().checked_add_signed(chrono::Duration::minutes(1)))
···294296 let token = auth::sign(
295297 &skey,
296298 "JWT",
297297- serde_json::json!({
299299+ &serde_json::json!({
298300 "iss": user_did.as_str(),
299301 "aud": did.as_str(),
300302 "lxm": lxm,
···313315 }
314316315317 let r = client
316316- .request(request.method().clone(), url)
318318+ .request(request.method().clone(), target_url)
317319 .headers(h)
318320 .header(http::header::AUTHORIZATION, format!("Bearer {token}"))
319321 .body(reqwest::Body::wrap_stream(
···338340/// The main application entry point.
339341#[expect(
340342 clippy::cognitive_complexity,
343343+ clippy::too_many_lines,
341344 reason = "main function has high complexity"
342345)]
343346async fn run() -> anyhow::Result<()> {
···346349 // Set up trace logging to console and account for the user-provided verbosity flag.
347350 if args.verbosity.log_level_filter() != LevelFilter::Off {
348351 let lvl = match args.verbosity.log_level_filter() {
349349- LevelFilter::Off => tracing::Level::INFO,
350352 LevelFilter::Error => tracing::Level::ERROR,
351353 LevelFilter::Warn => tracing::Level::WARN,
352352- LevelFilter::Info => tracing::Level::INFO,
354354+ LevelFilter::Info | LevelFilter::Off => tracing::Level::INFO,
353355 LevelFilter::Debug => tracing::Level::DEBUG,
354356 LevelFilter::Trace => tracing::Level::TRACE,
355357 };
···384386 }
385387386388 // Initialize metrics reporting.
387387- metrics::setup(&config.metrics).context("failed to set up metrics exporter")?;
389389+ metrics::setup(config.metrics.as_ref()).context("failed to set up metrics exporter")?;
388390389391 // Create a reqwest client that will be used for all outbound requests.
390392 let simple_client = reqwest::Client::builder()
···404406 .context("failed to create key directory")?;
405407406408 // Check if crypto keys exist. If not, create new ones.
407407- let (skey, rkey) = match std::fs::File::open(&config.key) {
408408- Ok(f) => {
409409- let keys: KeyData = serde_ipld_dagcbor::from_reader(std::io::BufReader::new(f))
410410- .context("failed to deserialize crypto keys")?;
409409+ let (skey, rkey) = if let Ok(f) = std::fs::File::open(&config.key) {
410410+ let keys: KeyData = serde_ipld_dagcbor::from_reader(std::io::BufReader::new(f))
411411+ .context("failed to deserialize crypto keys")?;
411412412412- let skey =
413413- Secp256k1Keypair::import(&keys.skey).context("failed to import signing key")?;
414414- let rkey =
415415- Secp256k1Keypair::import(&keys.rkey).context("failed to import rotation key")?;
413413+ let skey = Secp256k1Keypair::import(&keys.skey).context("failed to import signing key")?;
414414+ let rkey = Secp256k1Keypair::import(&keys.rkey).context("failed to import rotation key")?;
416415417417- (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey)))
418418- }
419419- _ => {
420420- info!("signing keys not found, generating new ones");
416416+ (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey)))
417417+ } else {
418418+ info!("signing keys not found, generating new ones");
421419422422- let skey = Secp256k1Keypair::create(&mut rand::thread_rng());
423423- let rkey = Secp256k1Keypair::create(&mut rand::thread_rng());
420420+ let skey = Secp256k1Keypair::create(&mut rand::thread_rng());
421421+ let rkey = Secp256k1Keypair::create(&mut rand::thread_rng());
424422425425- let keys = KeyData {
426426- skey: skey.export(),
427427- rkey: rkey.export(),
428428- };
423423+ let keys = KeyData {
424424+ skey: skey.export(),
425425+ rkey: rkey.export(),
426426+ };
429427430430- let mut f = std::fs::File::create(&config.key).context("failed to create key file")?;
431431- serde_ipld_dagcbor::to_writer(&mut f, &keys)
432432- .context("failed to serialize crypto keys")?;
428428+ let mut f = std::fs::File::create(&config.key).context("failed to create key file")?;
429429+ serde_ipld_dagcbor::to_writer(&mut f, &keys).context("failed to serialize crypto keys")?;
433430434434- (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey)))
435435- }
431431+ (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey)))
436432 };
437433438434 tokio::fs::create_dir_all(&config.repo.path).await?;
···451447 .await
452448 .context("failed to apply migrations")?;
453449454454- let (_fh, fhp) = firehose::spawn(client.clone(), config.clone()).await;
450450+ let (_fh, fhp) = firehose::spawn(client.clone(), config.clone());
455451456452 let addr = config
457453 .listen_address
···536532537533 serve
538534 .await
539539- .map_err(|e| e.into())
535535+ .map_err(Into::into)
540536 .and_then(|r| r)
541537 .context("failed to serve app")
542538}
+2-2
src/metrics.rs
···2828pub(crate) const REPO_OP_DELETE: &str = "bluepds.repo.op.delete";
29293030/// Must be ran exactly once on startup. This will declare all of the instruments for `metrics`.
3131-pub(crate) fn setup(config: &Option<config::MetricConfig>) -> anyhow::Result<()> {
3131+pub(crate) fn setup(config: Option<&config::MetricConfig>) -> anyhow::Result<()> {
3232 describe_counter!(AUTH_FAILED, "The number of failed authentication attempts.");
33333434 describe_gauge!(FIREHOSE_HISTORY, "The size of the firehose history buffer.");
···5353 describe_counter!(REPO_OP_UPDATE, "The count of updated records.");
5454 describe_counter!(REPO_OP_DELETE, "The count of deleted records.");
55555656- if let Some(ref config) = *config {
5656+ if let Some(config) = config {
5757 match *config {
5858 config::MetricConfig::PrometheusPush(ref prometheus_config) => {
5959 PrometheusBuilder::new()