From 59934cc1840d04abc251c6e8bf61b705f23b3a2c Mon Sep 17 00:00:00 2001 From: nelind Date: Sun, 19 Jul 2026 01:02:30 +0200 Subject: [PATCH] feat: add bsky and bsky-support cargo features to manage bsky specific code --- crates/tranquil-api/Cargo.toml | 4 ++ crates/tranquil-api/src/actor/mod.rs | 3 -- crates/tranquil-api/src/identity/provision.rs | 30 ++++++----- crates/tranquil-api/src/lib.rs | 37 +++++++++---- crates/tranquil-oauth-server/Cargo.toml | 3 ++ .../src/sso_endpoints.rs | 29 +++++----- crates/tranquil-pds/Cargo.toml | 2 + crates/tranquil-pds/src/api/proxy.rs | 53 +++++++++++-------- crates/tranquil-pds/src/api/proxy_client.rs | 12 ++++- crates/tranquil-pds/src/handle/reserved.rs | 3 ++ crates/tranquil-pds/src/repo_ops.rs | 4 ++ crates/tranquil-pds/src/types.rs | 2 + crates/tranquil-pds/src/util.rs | 9 +++- crates/tranquil-pds/src/validation/mod.rs | 6 +++ crates/tranquil-server/Cargo.toml | 4 +- 15 files changed, 135 insertions(+), 66 deletions(-) delete mode 100644 crates/tranquil-api/src/actor/mod.rs diff --git a/crates/tranquil-api/Cargo.toml b/crates/tranquil-api/Cargo.toml index 5459a0f..9f7c180 100644 --- a/crates/tranquil-api/Cargo.toml +++ b/crates/tranquil-api/Cargo.toml @@ -47,3 +47,7 @@ tracing = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } webauthn-rs = { workspace = true } + +[features] +bsky = ["bsky-support"] +bsky-support = [] diff --git a/crates/tranquil-api/src/actor/mod.rs b/crates/tranquil-api/src/actor/mod.rs deleted file mode 100644 index 4854235..0000000 --- a/crates/tranquil-api/src/actor/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod preferences; - -pub use preferences::{get_preferences, put_preferences}; diff --git a/crates/tranquil-api/src/identity/provision.rs b/crates/tranquil-api/src/identity/provision.rs index 23edb79..207d5c7 100644 --- a/crates/tranquil-api/src/identity/provision.rs +++ b/crates/tranquil-api/src/identity/provision.rs @@ -205,20 +205,24 @@ pub async fn sequence_new_account( { tracing::warn!("Failed to sequence sync event for {}: {}", did, e); } - let profile_record = serde_json::json!({ - "$type": "app.bsky.actor.profile", - "displayName": display_name - }); - if let Err(e) = tranquil_pds::repo_ops::create_record_internal( - state, - did, - &tranquil_pds::types::PROFILE_COLLECTION, - &tranquil_pds::types::PROFILE_RKEY, - &profile_record, - ) - .await + // TODO: make this configurable and also deduplicate with tranquil-oauth-server/src/sso_endpoints.rs:1210 + #[cfg(feature = "bsky")] { - tracing::warn!("Failed to create default profile for {}: {}", did, e); + let profile_record = serde_json::json!({ + "$type": "app.bsky.actor.profile", + "displayName": display_name + }); + if let Err(e) = tranquil_pds::repo_ops::create_record_internal( + state, + did, + &tranquil_pds::types::PROFILE_COLLECTION, + &tranquil_pds::types::PROFILE_RKEY, + &profile_record, + ) + .await + { + tracing::warn!("Failed to create default profile for {}: {}", did, e); + }; } } diff --git a/crates/tranquil-api/src/lib.rs b/crates/tranquil-api/src/lib.rs index 1a7d36e..6852e73 100644 --- a/crates/tranquil-api/src/lib.rs +++ b/crates/tranquil-api/src/lib.rs @@ -1,5 +1,12 @@ -pub mod actor; +// BSKY: Bluesky requires PDSs to implement its private preferences API +#[cfg(feature = "bsky-support")] +pub mod actor { + mod preferences; + + pub use preferences::{get_preferences, put_preferences}; +} pub mod admin; +#[cfg(feature = "bsky")] pub mod age_assurance; pub mod common; pub mod delegation; @@ -21,7 +28,7 @@ pub fn api_routes() -> axum::Router { let blob_body_limit = DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize); - axum::Router::new() + let router = axum::Router::new() .route("/_health", get(server::health)) .route( "/com.atproto.server.describeServer", @@ -373,14 +380,6 @@ pub fn api_routes() -> axum::Router { post(admin::update_subject_status), ) .route("/com.atproto.admin.sendEmail", post(admin::send_email)) - .route( - "/app.bsky.actor.getPreferences", - get(actor::get_preferences), - ) - .route( - "/app.bsky.actor.putPreferences", - post(actor::put_preferences), - ) .route( "/com.atproto.temp.checkSignupQueue", get(temp::check_signup_queue), @@ -438,7 +437,21 @@ pub fn api_routes() -> axum::Router { .route( "/_delegation.resolveController", get(delegation::resolve_controller), + ); + + #[cfg(feature = "bsky-support")] + let router = router + .route( + "/app.bsky.actor.getPreferences", + get(actor::get_preferences), ) + .route( + "/app.bsky.actor.putPreferences", + post(actor::put_preferences), + ); + + #[cfg(feature = "bsky")] + let router = router .route( "/app.bsky.ageassurance.getState", get(age_assurance::get_state), @@ -446,7 +459,9 @@ pub fn api_routes() -> axum::Router { .route( "/app.bsky.unspecced.getAgeAssuranceState", get(age_assurance::get_age_assurance_state), - ) + ); + + router } pub fn well_known_api_routes() -> axum::Router { diff --git a/crates/tranquil-oauth-server/Cargo.toml b/crates/tranquil-oauth-server/Cargo.toml index eab1e37..3cf9ce3 100644 --- a/crates/tranquil-oauth-server/Cargo.toml +++ b/crates/tranquil-oauth-server/Cargo.toml @@ -37,3 +37,6 @@ webauthn-rs = { workspace = true } [dev-dependencies] async-trait = { workspace = true } + +[features] +bsky = [] diff --git a/crates/tranquil-oauth-server/src/sso_endpoints.rs b/crates/tranquil-oauth-server/src/sso_endpoints.rs index 1f3b08a..db228d5 100644 --- a/crates/tranquil-oauth-server/src/sso_endpoints.rs +++ b/crates/tranquil-oauth-server/src/sso_endpoints.rs @@ -1209,20 +1209,23 @@ pub async fn complete_registration( tracing::warn!("Failed to sequence account event for {}: {}", did, e); } - let profile_record = json!({ - "$type": "app.bsky.actor.profile", - "displayName": handle.as_str() - }); - if let Err(e) = tranquil_pds::repo_ops::create_record_internal( - &state, - &did, - &tranquil_pds::types::PROFILE_COLLECTION, - &tranquil_pds::types::PROFILE_RKEY, - &profile_record, - ) - .await + #[cfg(feature = "bsky")] { - tracing::warn!("Failed to create default profile for {}: {}", did, e); + let profile_record = json!({ + "$type": "app.bsky.actor.profile", + "displayName": handle.as_str() + }); + if let Err(e) = tranquil_pds::repo_ops::create_record_internal( + &state, + &did, + &tranquil_pds::types::PROFILE_COLLECTION, + &tranquil_pds::types::PROFILE_RKEY, + &profile_record, + ) + .await + { + tracing::warn!("Failed to create default profile for {}: {}", did, e); + }; } let app_password = generate_app_password(); diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index 75b6a96..6e90db3 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -76,6 +76,8 @@ webauthn-rs = { workspace = true } webauthn-rs-proto = { workspace = true } [features] +bsky = ["bsky-support"] +bsky-support = [] external-infra = [] postgres = ["tranquil-db/postgres"] s3 = ["tranquil-storage/s3"] diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index bb2dadd..5e2af72 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -19,9 +19,7 @@ use tower::{Service, util::BoxCloneSyncService}; use tracing::{error, info, warn}; static PROTECTED_METHODS: LazyLock> = LazyLock::new(|| { - [ - "app.bsky.actor.getPreferences", - "app.bsky.actor.putPreferences", + let mut methods: HashSet<&str> = [ "com.atproto.admin.deleteAccount", "com.atproto.admin.disableAccountInvites", "com.atproto.admin.disableInviteCodes", @@ -103,7 +101,13 @@ static PROTECTED_METHODS: LazyLock> = LazyLock::new(|| { "com.atproto.temp.dereferenceScope", ] .into_iter() - .collect() + .collect(); + // BSKY: the Bluesky preferences API must be implemented by PDSs + if cfg!(feature = "bsky-support") { + methods.insert("app.bsky.actor.getPreferences"); + methods.insert("app.bsky.actor.putPreferences"); + }; + methods }); fn is_protected_method(method: &str) -> bool { @@ -111,6 +115,7 @@ fn is_protected_method(method: &str) -> bool { } /// Fetch the `feed` generator record from the AppView and return its `did`. +#[cfg(feature = "bsky-support")] async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option { #[derive(serde::Deserialize)] struct GetFeedQuery { @@ -201,6 +206,7 @@ impl> Service ( - feed_did, - "app.bsky.feed.getFeedSkeleton" - .parse::() - .expect("getFeedSkeleton is a valid NSID"), - ), - None => { - warn!( - "getFeed proxy: could not resolve feed generator DID; refusing \ + // BSKY: getFeed must be audienced to the feed generator, not the AppView. + let (token_aud, token_lxm) = + if cfg!(feature = "bsky-support") && method == "app.bsky.feed.getFeed" { + match resolve_feed_generator_did(&resolved.url, query.as_deref()).await { + Some(feed_did) => ( + feed_did, + "app.bsky.feed.getFeedSkeleton" + .parse::() + .expect("getFeedSkeleton is a valid NSID"), + ), + None => { + warn!( + "getFeed proxy: could not resolve feed generator DID; refusing \ to mint an AppView-audienced token" - ); - return ApiError::InvalidRequest("Could not resolve feed".into()) - .into_response(); + ); + return ApiError::InvalidRequest("Could not resolve feed".into()) + .into_response(); + } } - } - } else { - (resolved.did.clone(), method_nsid.clone()) - }; + } else { + (resolved.did.clone(), method_nsid.clone()) + }; match crate::auth::create_service_token( &auth_user.did, diff --git a/crates/tranquil-pds/src/api/proxy_client.rs b/crates/tranquil-pds/src/api/proxy_client.rs index 99e774d..0bef89e 100644 --- a/crates/tranquil-pds/src/api/proxy_client.rs +++ b/crates/tranquil-pds/src/api/proxy_client.rs @@ -147,7 +147,9 @@ impl std::fmt::Display for SsrfError { } impl std::error::Error for SsrfError {} - +// TODO: update to match https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/pipethrough.ts +// Currently spec says nothing at all!! about forwarding headers during proxying and https://github.com/bluesky-social/atproto/discussions/2350 +#[cfg(feature = "bsky-support")] pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| { [ HeaderName::from_static("accept-language"), @@ -156,6 +158,14 @@ pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| { http::header::CONTENT_TYPE, ] }); +#[cfg(not(feature = "bsky-support"))] +pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 3]> = LazyLock::new(|| { + [ + HeaderName::from_static("accept-language"), + crate::util::HEADER_ATPROTO_ACCEPT_LABELERS, + http::header::CONTENT_TYPE, + ] +}); pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| { [ crate::util::HEADER_ATPROTO_REPO_REV, diff --git a/crates/tranquil-pds/src/handle/reserved.rs b/crates/tranquil-pds/src/handle/reserved.rs index 2497213..81f480f 100644 --- a/crates/tranquil-pds/src/handle/reserved.rs +++ b/crates/tranquil-pds/src/handle/reserved.rs @@ -1,6 +1,9 @@ use std::collections::HashSet; use std::sync::LazyLock; +// TODO: make all of this configurable. +// PDS implementation should not impose any reserved domains imo. +// some of these are even bad to have as defaults let alone non-configurables const ATP_SPECIFIC: &[&str] = &[ "at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky", "bluesky", "handle", diff --git a/crates/tranquil-pds/src/repo_ops.rs b/crates/tranquil-pds/src/repo_ops.rs index 5ce9487..56c3cfb 100644 --- a/crates/tranquil-pds/src/repo_ops.rs +++ b/crates/tranquil-pds/src/repo_ops.rs @@ -115,6 +115,10 @@ pub fn extract_blob_cids(record: &Value) -> Vec { use crate::types::AtUri; use tranquil_db_traits::{Backlink, BacklinkPath}; +// TODO: it really really should not be necessary to extract backlinks and store those. +// especially not in a way that isnt generic. +// figure out what the fuck is going on here +// (lewis do you remember why you did this???) pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec { let record_type = record .get("$type") diff --git a/crates/tranquil-pds/src/types.rs b/crates/tranquil-pds/src/types.rs index c4a28e0..9a184f2 100644 --- a/crates/tranquil-pds/src/types.rs +++ b/crates/tranquil-pds/src/types.rs @@ -2,6 +2,8 @@ pub use tranquil_types::*; use std::sync::LazyLock; +#[cfg(feature = "bsky")] pub static PROFILE_COLLECTION: LazyLock = LazyLock::new(|| "app.bsky.actor.profile".parse().unwrap()); +#[cfg(feature = "bsky")] pub static PROFILE_RKEY: LazyLock = LazyLock::new(|| "self".parse().unwrap()); diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index dfaf57e..2b1a977 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -87,6 +87,7 @@ pub const HEADER_ATPROTO_ACCEPT_LABELERS: HeaderName = pub const HEADER_ATPROTO_REPO_REV: HeaderName = HeaderName::from_static("atproto-repo-rev"); pub const HEADER_ATPROTO_CONTENT_LABELERS: HeaderName = HeaderName::from_static("atproto-content-labelers"); +#[cfg(feature = "bsky-support")] pub const HEADER_X_BSKY_TOPICS: HeaderName = HeaderName::from_static("x-bsky-topics"); pub fn get_header_str( @@ -247,7 +248,13 @@ pub fn build_full_url(path: &str) -> String { let cfg = tranquil_config::get(); let normalized_path = if !path.starts_with("/xrpc/") && (path.starts_with("/com.atproto.") - || path.starts_with("/app.bsky.") + // BSKY: Bluesky requires that the PDS implement some app.bsky.* endpoints so we need to deal with those here too. + // TODO: surely we can figure out a way to do this more generically? + || (if cfg!(feature = "bsky-support") { + path.starts_with("/app.bsky.") + } else { + true + }) || path.starts_with("/_")) { format!("/xrpc{path}") diff --git a/crates/tranquil-pds/src/validation/mod.rs b/crates/tranquil-pds/src/validation/mod.rs index 1b858c2..b94ab83 100644 --- a/crates/tranquil-pds/src/validation/mod.rs +++ b/crates/tranquil-pds/src/validation/mod.rs @@ -138,20 +138,25 @@ fn check_banned_content( rkey: Option<&Rkey>, ) -> Result<(), ValidationError> { match record_type { + #[cfg(feature = "bsky")] "app.bsky.feed.post" => { check_post_banned_content(obj)?; } + #[cfg(feature = "bsky")] "app.bsky.actor.profile" => { check_string_field(obj, "displayName")?; check_string_field(obj, "description")?; } + #[cfg(feature = "bsky")] "app.bsky.graph.list" => { check_string_field(obj, "name")?; } + #[cfg(feature = "bsky")] "app.bsky.graph.starterpack" => { check_string_field(obj, "name")?; check_string_field(obj, "description")?; } + #[cfg(feature = "bsky")] "app.bsky.feed.generator" => { if let Some(rkey) = rkey && crate::moderation::has_explicit_slur(rkey) @@ -167,6 +172,7 @@ fn check_banned_content( Ok(()) } +#[cfg(feature = "bsky")] fn check_post_banned_content(obj: &serde_json::Map) -> Result<(), ValidationError> { if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { tags.iter().enumerate().try_for_each(|(i, tag)| { diff --git a/crates/tranquil-server/Cargo.toml b/crates/tranquil-server/Cargo.toml index 4e1d7ae..b214a4c 100644 --- a/crates/tranquil-server/Cargo.toml +++ b/crates/tranquil-server/Cargo.toml @@ -41,7 +41,9 @@ tracing-subscriber = { workspace = true } rcgen = { workspace = true } [features] -default = ["frontend", "postgres", "s3", "valkey"] +default = ["bsky", "frontend", "postgres", "s3", "valkey"] +bsky = ["bsky-support", "tranquil-pds/bsky", "tranquil-api/bsky"] +bsky-support = ["tranquil-pds/bsky-support", "tranquil-api/bsky-support"] frontend = ["tranquil-pds/frontend"] postgres = ["tranquil-pds/postgres"] s3 = ["tranquil-pds/s3"]