Compare commits

..
Author SHA1 Message Date
Lewis 37234797b4 just: clippy over all targets, lint the bsky-off build
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 20:14:11 +03:00
Lewis 6297b1a451 cache: DID, SSO, & OAuth client metadata caches onto shared cache
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 20:05:16 +03:00
Lewis 107149f396 lexicon: schema docs & negative results via cluster cache
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 20:05:16 +03:00
Lewis bd47cbdaa4 plc: dedup fetch paths, cache TTL from config
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 20:05:16 +03:00
Lewis 9840ac77cf auth: EmailTokenPurpose from tranquil-types, shared cache key fns, MemoryCache in tests
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 20:05:16 +03:00
Lewis 420ce1e201 types: HttpUrl newtypes, shared cache key/JSON helpers
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 20:05:16 +03:00
Lewis c723bc2164 pds: compile bsky-specific proxy, CORS, & validation out under bsky features
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 20:05:16 +03:00
63 changed files with 379 additions and 2123 deletions
Generated
-2
View File
@@ -7780,8 +7780,6 @@ name = "tranquil-config"
version = "0.6.6"
dependencies = [
"confique",
"serde",
"tranquil-types",
]
[[package]]
-2
View File
@@ -1,7 +1,6 @@
[server]
hostname = "pds.test"
allow_http_proxy = true
allow_private_fetch = true
invite_code_required = false
disable_rate_limiting = true
@@ -11,7 +10,6 @@ dir = "/app/frontend/public"
[database]
url = "postgres://postgres:postgres@db:5432/pds"
max_connections = 20
[storage]
path = "/var/lib/tranquil-pds/blobs"
@@ -66,11 +66,11 @@ pub async fn update_account_handle(
{
return Err(ApiError::InvalidHandle(None));
}
let primary = tranquil_pds::handle::ServiceDomains::for_user_handles().primary();
let handle = if input_handle.contains('.') {
input_handle.to_string()
let available_domains = tranquil_config::get().server.available_user_domain_list();
let handle = if !input_handle.contains('.') {
format!("{}.{}", input_handle, &available_domains[0])
} else {
format!("{}.{}", input_handle, primary)
input_handle.to_string()
};
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
let user_id = state
+26 -13
View File
@@ -132,9 +132,12 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
if host_without_port != hostname_without_port {
let is_subdomain = tranquil_pds::handle::ServiceDomains::served()
.split_handle(host_without_port)
.is_some();
let is_subdomain = cfg
.server
.available_user_domain_list()
.into_iter()
.chain(std::iter::once(hostname_without_port.to_string()))
.any(|d| host_without_port.ends_with(&format!(".{}", d)));
if is_subdomain {
return serve_handle_did_doc(&state, host_without_port, hostname).await;
}
@@ -579,16 +582,26 @@ pub async fn update_handle(
"Inappropriate language in handle".into(),
)));
}
let handle_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
let split = handle_domains.split_handle(&new_handle);
let is_domain_itself = handle_domains.contains(&new_handle);
let handle: Handle = if (!new_handle.contains('.') || split.is_some()) && !is_domain_itself {
let (short_part, full_handle) = match split {
Some((_domain, short)) => (short.to_string(), new_handle.clone()),
None => (
new_handle.clone(),
format!("{}.{}", new_handle, handle_domains.primary()),
),
let handle_domains = tranquil_config::get().server.user_handle_domain_list();
let matched_handle_domain = handle_domains
.iter()
.filter(|d| new_handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len())
.cloned();
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
let handle: Handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
&& !is_domain_itself
{
let (short_part, full_handle) = match &matched_handle_domain {
Some(domain) => {
let suffix = format!(".{}", domain);
let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle);
(short.to_string(), new_handle.clone())
}
None => {
let primary = &handle_domains[0];
(new_handle.clone(), format!("{}.{}", new_handle, primary))
}
};
if full_handle == current_handle {
let handle: Handle = match full_handle.parse() {
+16 -1
View File
@@ -9,7 +9,10 @@ use tranquil_pds::api::ApiError;
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::auth::{Auth, Permissive};
use tranquil_pds::circuit_breaker::with_circuit_breaker;
use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation};
use tranquil_pds::plc::{
PlcError, PlcService, create_update_op, missing_required_rotation_key, sign_operation,
signing_key_to_did_key,
};
use tranquil_pds::state::AppState;
#[derive(Debug, Deserialize)]
@@ -115,6 +118,18 @@ pub async fn sign_plc_operation(
}
})?;
let signing_did_key = signing_key_to_did_key(&signing_key);
if let Some(rotation_keys) = unsigned_op.get("rotationKeys").and_then(Value::as_array) {
let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect();
if let Some(missing) = missing_required_rotation_key(
&rotation_key_strs,
&signing_did_key,
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
) {
return Err(ApiError::InvalidRequest(missing.message().into()));
}
}
let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| {
error!("Failed to sign PLC operation: {:?}", e);
ApiError::InternalError(None)
+2 -8
View File
@@ -467,15 +467,9 @@ pub fn api_routes() -> axum::Router<AppState> {
pub fn well_known_api_routes() -> axum::Router<AppState> {
use axum::routing::get;
let routes = axum::Router::new()
axum::Router::new()
.route("/did.json", get(identity::well_known_did))
.route("/atproto-did", get(identity::well_known_atproto_did));
if tranquil_config::get().server.enable_caddy_on_demand_tls {
routes.route("/caddy/ask", get(server::caddy_ask))
} else {
routes
}
.route("/atproto-did", get(identity::well_known_atproto_did))
}
pub fn webhook_routes() -> axum::Router<AppState> {
+2 -3
View File
@@ -12,7 +12,7 @@ use tranquil_pds::api::ApiError;
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
use tranquil_pds::auth::{AnyUser, Auth};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, DidRef, Nsid};
use tranquil_pds::types::{Did, Nsid};
static CREATE_REPORT_NSID: LazyLock<Nsid> =
LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
@@ -151,9 +151,8 @@ async fn proxy_to_report_service(
let service_token = match tranquil_pds::auth::create_service_token(
&auth_user.did,
&DidRef::from(service_did),
service_did,
Some(&CREATE_REPORT_NSID),
None,
&key_bytes,
) {
Ok(t) => t,
-43
View File
@@ -1,43 +0,0 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use tracing::error;
use tranquil_pds::handle::ServiceDomains;
use tranquil_pds::state::AppState;
use tranquil_pds::types::Handle;
pub struct AskedDomain(Handle);
impl<'de> Deserialize<'de> for AskedDomain {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
let without_root_dot = raw.strip_suffix('.').unwrap_or(&raw);
Handle::new(without_root_dot)
.map(Self)
.map_err(D::Error::custom)
}
}
#[derive(Deserialize)]
pub struct CaddyAskQuery {
pub domain: AskedDomain,
}
pub async fn caddy_ask(
State(state): State<AppState>,
Query(ask): Query<CaddyAskQuery>,
) -> StatusCode {
let AskedDomain(handle) = ask.domain;
if ServiceDomains::served().contains(handle.as_str()) {
return StatusCode::OK;
}
match state.repos.user.get_by_handle(&handle).await {
Ok(Some(_)) => StatusCode::OK,
Ok(None) => StatusCode::NOT_FOUND,
Err(e) => {
error!("caddy ask couldn't look up handle {handle}: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
+1 -6
View File
@@ -77,12 +77,7 @@ pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServ
let pds_hostname = &cfg.server.hostname;
Json(DescribeServerOutput {
available_user_domains: match cfg.server.user_handle_domains.as_deref() {
Some(domains) if !domains.is_empty() => {
domains.iter().map(|d| d.as_str().to_owned()).collect()
}
_ => vec![cfg.server.hostname_without_port().to_owned()],
},
available_user_domains: cfg.server.user_handle_domain_list(),
invite_code_required: cfg.server.invite_code_required,
did: format!("did:web:{}", pds_hostname),
links: DescribeServerLinks {
-2
View File
@@ -1,6 +1,5 @@
pub mod account_status;
pub mod app_password;
pub mod caddy;
pub mod email;
pub mod invite;
pub mod logo;
@@ -23,7 +22,6 @@ pub use account_status::{
request_account_delete,
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use caddy::caddy_ask;
pub use email::{
authorize_email_update, check_channel_verified, check_email_in_use, check_email_update_status,
check_email_verified, confirm_email, request_email_update, update_email,
+10 -15
View File
@@ -11,7 +11,7 @@ use tracing::{error, info, warn};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::extractor::{Auth, Permissive};
use tranquil_pds::state::AppState;
use tranquil_pds::types::DidRef;
use tranquil_pds::types::Did;
use tranquil_types::Nsid;
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
@@ -45,7 +45,7 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
#[derive(Deserialize)]
pub struct GetServiceAuthParams {
pub aud: DidRef,
pub aud: Did,
pub lxm: Option<Nsid>,
pub exp: Option<i64>,
}
@@ -169,19 +169,14 @@ pub async fn get_service_auth(
}
}
let service_token = match tranquil_pds::auth::create_service_token(
&auth.did,
&params.aud,
lxm,
params.exp,
&key_bytes,
) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let service_token =
match tranquil_pds::auth::create_service_token(&auth.did, &params.aud, lxm, &key_bytes) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
(
StatusCode::OK,
Json(GetServiceAuthOutput {
+6 -10
View File
@@ -10,7 +10,7 @@ use chrono::{DateTime, Duration, Utc};
use hmac::{Hmac, Mac};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use sha2::Sha256;
use tranquil_types::{Did, DidRef, Jti, Nsid};
use tranquil_types::{Did, Jti, Nsid};
type HmacSha256 = Hmac<Sha256>;
@@ -127,20 +127,16 @@ pub fn create_refresh_token_with_jti(
pub fn create_service_token(
did: &Did,
aud: &DidRef,
aud: &Did,
lxm: Option<&Nsid>,
exp: Option<i64>,
key_bytes: &[u8],
) -> Result<String> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = match exp {
Some(exp) => exp,
None => Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp(),
};
let expiration = Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp();
let claims = Claims {
iss: did.clone(),
+8 -123
View File
@@ -1,41 +1,25 @@
use lettre::Message;
use lettre::message::Mailbox;
use lettre::message::header::ContentType;
use lettre::message::header::{Header, HeaderName, HeaderValue};
use uuid::Uuid;
use super::types::EmailDomain;
use crate::sender::SendError;
use crate::types::{CommsType, QueuedComms};
use crate::types::QueuedComms;
pub(super) fn build(
from: &Mailbox,
qc: &QueuedComms,
apply_atmos_categories: bool,
) -> Result<Message, SendError> {
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
let to: Mailbox = qc
.recipient
.parse()
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
let subject = qc.subject.as_deref().unwrap_or("Notification");
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
let builder = Message::builder()
Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.message_id(Some(message_id))
.header(ContentType::TEXT_PLAIN);
let category = apply_atmos_categories
.then(|| atmos_category(qc.comms_type))
.flatten();
let builder = match category {
Some(category) => builder.header(category),
None => builder,
};
builder
.header(ContentType::TEXT_PLAIN)
.body(qc.body.clone())
.map_err(|e| SendError::MessageBuild(e.to_string()))
}
@@ -50,57 +34,10 @@ pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendErr
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
}
// for use with comail.at
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
enum AtmosCategory {
PasswordReset,
MfaOtp,
Verification,
}
impl AtmosCategory {
fn as_str(self) -> &'static str {
match self {
Self::PasswordReset => "password-reset",
Self::MfaOtp => "mfa-otp",
Self::Verification => "verification",
}
}
}
impl Header for AtmosCategory {
fn name() -> HeaderName {
HeaderName::new_from_ascii_str("X-Atmos-Category")
}
fn parse(_s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
//since we're never receiving email, we don't care about parsing
Err("X-Atmos-Category is write-only".into())
}
fn display(&self) -> HeaderValue {
HeaderValue::new(Self::name(), self.as_str().to_string())
}
}
fn atmos_category(comms_type: CommsType) -> Option<AtmosCategory> {
use CommsType::*;
match comms_type {
EmailVerification
| ChannelVerification
| ChannelVerified
| MigrationVerification
| LegacyLoginAlert
| EmailUpdate
| PlcOperation
| AccountDeletion
| Welcome => Some(AtmosCategory::Verification),
PasswordReset | PasskeyRecovery => Some(AtmosCategory::PasswordReset),
TwoFactorCode => Some(AtmosCategory::MfaOtp),
AdminEmail => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CommsChannel, CommsStatus};
use crate::types::{CommsChannel, CommsStatus, CommsType};
use chrono::Utc;
use uuid::Uuid;
@@ -134,7 +71,6 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -151,7 +87,6 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -164,7 +99,6 @@ mod tests {
let result = build(
&from_mailbox(),
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
false,
);
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
}
@@ -174,7 +108,6 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
false,
)
.expect("subject CRLF should be encoded, not rejected");
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -190,12 +123,7 @@ mod tests {
#[test]
fn message_id_uses_from_domain() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("s"), "b"),
false,
)
.unwrap();
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let line = raw
.lines()
@@ -209,58 +137,15 @@ mod tests {
#[test]
fn missing_subject_uses_default() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", None, "Body"),
false,
)
.unwrap();
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("Subject: Notification"));
}
#[test]
fn recipient_domain_extracted() {
let msg = build(
&from_mailbox(),
&fixture("user@Nel.PET", Some("s"), "b"),
false,
)
.unwrap();
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
let d = recipient_domain(&msg).unwrap();
assert_eq!(d.as_str(), "nel.pet");
}
#[test]
fn atmos_category_header_present_when_enabled_and_mapped() {
let qc = QueuedComms {
comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, true).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("X-Atmos-Category: password-reset"));
}
#[test]
fn atmos_category_header_absent_when_disabled() {
let qc = QueuedComms {
comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, false).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category"));
}
#[test]
fn atmos_category_header_absent_when_unmapped() {
let qc = QueuedComms {
comms_type: CommsType::AdminEmail,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, true).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category"));
}
}
+1 -55
View File
@@ -124,7 +124,6 @@ fn build_smarthost(
Ok(SendMode::Smarthost {
transport: Box::new(builder.build()),
total_timeout,
apply_atmos_categories: cfg.email.smarthost.apply_atmos_categories,
})
}
@@ -177,16 +176,6 @@ fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, S
DkimSigner::load(selector, domain, path).map(Some)
}
fn wants_atmos_categories(mode: &SendMode) -> bool {
match mode {
SendMode::Smarthost {
apply_atmos_categories,
..
} => *apply_atmos_categories,
SendMode::DirectMx { .. } => false,
}
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
@@ -194,8 +183,7 @@ impl CommsSender for EmailSender {
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let mut message =
message::build(&self.from, notification, wants_atmos_categories(&self.mode))?;
let mut message = message::build(&self.from, notification)?;
if let Some(signer) = &self.dkim {
signer.sign(&mut message);
}
@@ -208,45 +196,3 @@ impl CommsSender for EmailSender {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lettre::Tokio1Executor;
use std::time::Duration;
fn dummy_smarthost(apply_atmos_categories: bool) -> SendMode {
let transport =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost").build();
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout: Duration::from_secs(10),
apply_atmos_categories,
}
}
fn dummy_direct_mx() -> SendMode {
SendMode::DirectMx {
resolver: Arc::new(TokioAsyncResolver::tokio(
ResolverConfig::default(),
ResolverOpts::default(),
)),
helo: HeloName::parse("mta.nel.pet").unwrap(),
command_timeout: Duration::from_secs(5),
total_timeout: Duration::from_secs(10),
require_tls: false,
inflight: Arc::new(Semaphore::new(1)),
}
}
#[tokio::test]
async fn smarthost_reflects_its_own_flag() {
assert!(wants_atmos_categories(&dummy_smarthost(true)));
assert!(!wants_atmos_categories(&dummy_smarthost(false)));
}
#[test]
fn direct_mx_never_wants_atmos_categories() {
assert!(!wants_atmos_categories(&dummy_direct_mx()));
}
}
+2 -11
View File
@@ -19,7 +19,6 @@ pub enum SendMode {
Smarthost {
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
total_timeout: Duration,
apply_atmos_categories: bool,
},
DirectMx {
resolver: Arc<TokioAsyncResolver>,
@@ -34,15 +33,8 @@ pub enum SendMode {
impl std::fmt::Debug for SendMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Smarthost {
total_timeout,
apply_atmos_categories,
..
} => {
write!(
f,
"SendMode::Smarthost(total_timeout={total_timeout:?}, apply_atmos_categories={apply_atmos_categories:?})"
)
Self::Smarthost { total_timeout, .. } => {
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
}
Self::DirectMx {
helo, require_tls, ..
@@ -60,7 +52,6 @@ pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError
SendMode::Smarthost {
transport,
total_timeout,
..
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
SendMode::DirectMx {
resolver,
@@ -53,7 +53,6 @@ fn build_smarthost_sender_with_total_timeout(
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout,
apply_atmos_categories: false,
},
None,
)
-2
View File
@@ -5,6 +5,4 @@ edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
tranquil-types = { workspace = true }
confique = { workspace = true }
+21 -31
View File
@@ -2,7 +2,6 @@ use confique::Config;
use std::fmt;
use std::path::PathBuf;
use std::sync::OnceLock;
use tranquil_types::Domain;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
@@ -31,6 +30,7 @@ impl fmt::Display for ConfigError {
}
impl std::error::Error for ConfigError {}
/// Initialize the global configuration. Must be called once at startup before
/// any other code accesses the configuration. Panics if called more than once.
pub fn init(config: TranquilConfig) {
@@ -224,12 +224,6 @@ impl TranquilConfig {
}
}
if let Err(e) = Domain::new(self.server.hostname_without_port()) {
errors.push(format!(
"server.hostname (PDS_HOSTNAME) must be a plain domain, {e}"
));
}
// -- email -----------------------------------------------------------
self.email
.validate(self.server.hostname_without_port(), &mut errors);
@@ -434,7 +428,7 @@ pub struct ServerConfig {
pub hostname: String,
/// Address to bind the HTTP server to.
#[config(env = "SERVER_HOST", default = "[::1]")]
#[config(env = "SERVER_HOST", default = "127.0.0.1")]
pub host: String,
/// Port to bind the HTTP server to.
@@ -444,21 +438,13 @@ pub struct ServerConfig {
/// List of domains for user handles.
/// Defaults to the PDS hostname when not set.
#[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)]
pub user_handle_domains: Option<Vec<Domain>>,
pub user_handle_domains: Option<Vec<String>>,
/// Enable PDS-hosted did:web identities. Hosting did:web requires a
/// long-term commitment to serve DID documents; opt-in only.
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
pub enable_pds_hosted_did_web: bool,
/// The caddy on-demand TLS requires we serve
/// the endpoint `/.well-known/caddy/ask`.
/// It will be used so that caddy can create TLS
/// certs for us on the fly
/// and we don't have to do annoying wildcard certs.
#[config(env = "ENABLE_CADDY_ON_DEMAND_TLS", default = true)]
pub enable_caddy_on_demand_tls: bool,
/// iykyk!
#[config(env = "RFC_MOO_COMPLIANCE", default = false)]
pub rfc_moo_compliance: bool,
@@ -479,10 +465,6 @@ pub struct ServerConfig {
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
pub disable_rate_limiting: bool,
/// Allow outbound fetches to private network addresses. Useful for local development using docker compose.
#[config(env = "ALLOW_PRIVATE_FETCH", default = false)]
pub allow_private_fetch: bool,
/// Skip the verified-comms-channel gate for login and record writes.
/// Please keep this off unless you're an invite-only PDS!
#[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)]
@@ -587,6 +569,20 @@ impl ServerConfig {
pub fn banned_word_list(&self) -> Vec<String> {
self.banned_words.clone().unwrap_or_default()
}
/// Returns the user handle domains, falling back to `[hostname_without_port]`.
pub fn user_handle_domain_list(&self) -> Vec<String> {
self.user_handle_domains
.as_deref()
.filter(|v| !v.is_empty())
.map(|v| v.to_vec())
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
}
/// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field).
pub fn available_user_domain_list(&self) -> Vec<String> {
self.user_handle_domain_list()
}
}
#[derive(Debug, Config)]
@@ -1128,10 +1124,6 @@ pub struct SmarthostConfig {
/// stuck relay cannot stall the comms queue.
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
pub total_timeout_secs: u64,
/// Apply Atmos/Comail.at categories for headers to be categorized appropriately.
#[config(env = "MAIL_APPLY_ATMOS_CATEGORIES", default = false)]
pub apply_atmos_categories: bool,
}
#[derive(Debug, Config)]
@@ -1484,13 +1476,12 @@ pub struct ImportConfig {
/// trimming whitespace and dropping empty entries.
///
/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`.
fn split_comma_list<T: std::str::FromStr>(value: &str) -> Result<Vec<T>, T::Err> {
value
fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> {
Ok(value
.split(',')
.map(str::trim)
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.map(T::from_str)
.collect()
.collect())
}
#[derive(Debug, Config)]
@@ -1994,7 +1985,6 @@ port = 587
pool_size: 4,
command_timeout_secs: 30,
total_timeout_secs: 60,
apply_atmos_categories: false,
},
direct_mx: DirectMxConfig {
command_timeout_secs: 30,
+8 -47
View File
@@ -34,28 +34,15 @@ pub fn is_valid_datetime(s: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(s).is_ok()
}
/// Checks the scheme only, not the character set or structure of what
/// follows. The aim is to accept at least all valid URIs; we can always
/// tighten this later. It does not parse the authority, because at-uris
/// put colons in the authority (at://did:plc:abc123/collection/rkey) and
/// any 3986 authority parser reads that as a non-numeric port and rejects
/// it.
pub fn is_valid_uri(s: &str) -> bool {
let Some((scheme, rest)) = s.split_once(':') else {
return false;
};
let valid_scheme = !scheme.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-' || c == '_');
if !valid_scheme {
return false;
}
match rest.strip_prefix("//") {
Some(authority_and_path) => !authority_and_path.is_empty(),
None => true,
}
s.split_once("://").is_some_and(|(scheme, rest)| {
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& !rest.is_empty()
})
}
pub fn is_valid_cid(s: &str) -> bool {
@@ -164,32 +151,6 @@ mod tests {
assert!(!is_valid_uri("https://"));
}
#[test]
fn test_valid_uris_without_authority() {
// RFC 3986 hier-part doesn't require "//": scheme ":" opaque-part is also a URI.
assert!(is_valid_uri("urn:isbn:9780141439518"));
assert!(is_valid_uri("mailto:user@example.com"));
assert!(is_valid_uri("mbid:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5")); //authority-less / path-rootless
assert!(is_valid_uri(
"has_an_underscore:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5"
));
assert!(is_valid_uri("urn:"));
}
#[test]
fn test_invalid_uri_without_scheme() {
assert!(!is_valid_uri(":no-scheme"));
}
#[test]
fn test_valid_uris_dont_reject_at_uri_authority_colons() {
// at-uri authorities contain colons (did:plc:...); is_valid_uri must not
// reject them the way a strict RFC 3986 authority parser would.
assert!(is_valid_uri(
"at://did:plc:cwdkf4xxjpznceembuuspt3d/sh.tangled.repo.pull/3mtjn7zouwn22"
));
}
#[test]
fn test_valid_cids() {
assert!(is_valid_cid("bafyreiabcdef123456"));
@@ -1,5 +1,4 @@
use super::*;
use tranquil_scopes::{ParsedScope, parse_scope};
use tranquil_types::Nsid;
#[derive(Debug, Serialize)]
@@ -11,7 +10,6 @@ pub struct ScopeInfo {
pub display_name: String,
pub granted: Option<bool>,
pub restricted: bool,
pub superseded: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_scope: Option<String>,
}
@@ -29,7 +27,6 @@ pub struct PermissionSetInfo {
pub expanded: Vec<ScopeInfo>,
pub granted: Option<bool>,
pub restricted: bool,
pub superseded: bool,
}
#[derive(Debug, Serialize)]
@@ -52,7 +49,6 @@ pub struct ConsentResponse {
pub logo_uri: Option<String>,
pub scopes: Vec<ScopeInfo>,
pub permission_sets: Vec<PermissionSetInfo>,
pub transition_supersedes: bool,
pub failed_sets: Vec<FailedSetInfo>,
pub show_consent: bool,
pub did: Did,
@@ -189,9 +185,6 @@ pub async fn consent_get(
.await
.unwrap_or(true);
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
let has_transition_generic = requested_scopes
.iter()
.any(|s| matches!(parse_scope(s), ParsedScope::TransitionGeneric));
let grant_scope_str: Option<&str> =
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
@@ -244,8 +237,6 @@ pub async fn consent_get(
)
};
let granted = pref_map.get(scope).copied();
let superseded = has_transition_generic
&& tranquil_scopes::superseded_by_transition_generic(&parse_scope(scope));
ScopeInfo {
scope: scope.to_string(),
category,
@@ -254,7 +245,6 @@ pub async fn consent_get(
display_name,
granted,
restricted,
superseded,
effective_scope,
}
};
@@ -277,7 +267,6 @@ pub async fn consent_get(
};
let expanded: Vec<ScopeInfo> = g.expanded.iter().map(|s| make_scope_info(s)).collect();
let restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
let superseded = !expanded.is_empty() && expanded.iter().all(|s| s.superseded);
PermissionSetInfo {
nsid: g.nsid.clone(),
aud: g.aud.clone(),
@@ -287,7 +276,6 @@ pub async fn consent_get(
include_scope,
expanded,
restricted,
superseded,
}
})
.collect();
@@ -344,9 +332,6 @@ pub async fn consent_get(
(None, None, None, None)
};
let transition_supersedes =
scopes.iter().any(|s| s.superseded) || permission_sets.iter().any(|s| s.superseded);
Json(ConsentResponse {
request_uri: query.request_uri.clone(),
client_id: request_data.parameters.client_id.clone(),
@@ -355,7 +340,6 @@ pub async fn consent_get(
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
scopes,
permission_sets,
transition_supersedes,
failed_sets,
show_consent,
did: did.clone(),
@@ -187,6 +187,32 @@ fn validate_scope(
)));
}
let has_transition = requested_scopes.iter().any(|s| {
matches!(
parse_scope(s),
ParsedScope::TransitionGeneric
| ParsedScope::TransitionChat
| ParsedScope::TransitionEmail
)
});
let has_granular = requested_scopes.iter().any(|s| {
matches!(
parse_scope(s),
ParsedScope::Repo(_)
| ParsedScope::Blob(_)
| ParsedScope::Rpc(_)
| ParsedScope::Account(_)
| ParsedScope::Identity(_)
| ParsedScope::Include(_)
)
});
if has_transition && has_granular {
return Err(OAuthError::InvalidScope(
"Cannot mix transition scopes with granular scopes. Use either transition:* scopes OR granular scopes (repo:*, blob:*, rpc:*, account:*, include:*), not both.".to_string()
));
}
if let Some(client_scope) = &client_metadata.scope {
let client_scopes: Vec<&str> = client_scope.split_whitespace().collect();
if let Some(unregistered) = requested_scopes
@@ -789,16 +789,13 @@ pub async fn check_handle_available(
}
};
let available_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
if let Some(d) = &query.domain
&& !available_domains.contains(d.as_str())
let available_domains = tranquil_config::get().server.available_user_domain_list();
if let Some(ref d) = query.domain
&& !available_domains.iter().any(|ad| ad == d)
{
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
}
let domain = query
.domain
.as_deref()
.unwrap_or_else(|| available_domains.primary().as_str());
let domain = query.domain.as_deref().unwrap_or(&available_domains[0]);
let full_handle = format!("{}.{}", validated, domain);
let handle: tranquil_pds::types::Handle = match full_handle.parse() {
Ok(h) => h,
@@ -885,33 +882,34 @@ pub async fn complete_registration(
let cfg = tranquil_config::get();
let hostname = &cfg.server.hostname;
let available_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
let available_domains = cfg.server.available_user_domain_list();
let split = available_domains.split_handle(&input.handle);
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let handle: tranquil_pds::types::Handle = if !input.handle.contains('.') || split.is_some() {
let handle_to_validate = match split {
Some((_domain, short)) => short,
None => input.handle.as_str(),
let handle: tranquil_pds::types::Handle =
if !input.handle.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input
.handle
.strip_suffix(&format!(".{}", domain))
.unwrap_or(&input.handle),
None => &input.handle,
};
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0]))
.parse()
.map_err(|_| ApiError::InvalidHandle(None))?,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
} else {
match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
};
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!(
"{}.{}",
h,
split
.map(|(d, _)| d)
.unwrap_or_else(|| available_domains.primary())
)
.parse()
.map_err(|_| ApiError::InvalidHandle(None))?,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
} else {
match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
};
let verification_channel = input
.verification_channel
+5 -7
View File
@@ -72,11 +72,10 @@ pub struct ClientMetadataCache {
cache: Arc<dyn Cache>,
http_client: Client,
cache_ttl: Duration,
fetch_policy: ReachPolicy,
}
impl ClientMetadataCache {
pub fn new(cache: Arc<dyn Cache>, cache_ttl: Duration, fetch_policy: ReachPolicy) -> Self {
pub fn new(cache: Arc<dyn Cache>, cache_ttl: Duration) -> Self {
Self {
cache,
http_client: {
@@ -85,8 +84,8 @@ impl ClientMetadataCache {
.connect_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.redirect(redirect_policy(fetch_policy))
.dns_resolver(dns_guard(fetch_policy))
.redirect(redirect_policy(ReachPolicy::DEBUG_LOOPBACK))
.dns_resolver(dns_guard(ReachPolicy::DEBUG_LOOPBACK))
.user_agent(concat!(
"Tranquil-PDS/",
env!("CARGO_PKG_VERSION"),
@@ -99,7 +98,6 @@ impl ClientMetadataCache {
.expect("failed to build client metadata HTTP client")
},
cache_ttl,
fetch_policy,
}
}
@@ -144,7 +142,7 @@ impl ClientMetadataCache {
response_types: vec!["code".into()],
scope,
token_endpoint_auth_method: Some("none".into()),
dpop_bound_access_tokens: Some(true),
dpop_bound_access_tokens: Some(false),
jwks: None,
jwks_uri: None,
application_type: Some("native".into()),
@@ -251,7 +249,7 @@ impl ClientMetadataCache {
async fn fetch_metadata(&self, client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
let url = reqwest::Url::parse(client_id)
.map_err(|_| OAuthError::InvalidClient("client_id must be a URL".to_string()))?;
if !url_reach_permits(&url, self.fetch_policy) {
if !url_reach_permits(&url, ReachPolicy::DEBUG_LOOPBACK) {
return Err(OAuthError::InvalidClient(
"client_id must be an https URL inside the allowed host reach".to_string(),
));
+2 -1
View File
@@ -763,7 +763,8 @@ impl From<crate::api::validation::HandleValidationError> for ApiError {
HandleValidationError::BannedWord => {
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
}
HandleValidationError::UnusableHandleDomain => Self::InternalError(Some(e.to_string())),
HandleValidationError::UnusableHandleDomain
| HandleValidationError::NoHandleDomains => Self::InternalError(Some(e.to_string())),
_ => Self::InvalidHandle(Some(e.to_string())),
}
}
+2 -3
View File
@@ -5,7 +5,7 @@ use std::sync::LazyLock;
use crate::api::error::ApiError;
use crate::api::proxy_client::proxy_client;
use crate::state::AppState;
use crate::types::{Did, DidRef, Nsid};
use crate::types::{Did, Nsid};
use crate::util::get_header_str;
use axum::{
body::Bytes,
@@ -361,9 +361,8 @@ async fn proxy_handler(
match crate::auth::create_service_token(
&auth_user.did,
&DidRef::from(token_aud),
&token_aud,
Some(&token_lxm),
None,
&key_bytes,
) {
Ok(new_token) => {
+22 -9
View File
@@ -111,6 +111,7 @@ pub enum HandleValidationError {
InvalidSyntax,
DisallowedTld,
UnusableHandleDomain,
NoHandleDomains,
}
impl std::fmt::Display for HandleValidationError {
@@ -142,6 +143,9 @@ impl std::fmt::Display for HandleValidationError {
f,
"This server's handle domain has a reserved TLD, so no handle under it is a valid atproto handle"
),
Self::NoHandleDomains => {
write!(f, "No handle domains are configured on this server")
}
}
}
}
@@ -211,14 +215,21 @@ pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationErr
}
pub fn resolve_handle_input(input: &str) -> Result<Handle, HandleValidationError> {
let domains = crate::handle::ServiceDomains::for_user_handles();
let split = domains.split_handle(input);
let available_domains = tranquil_config::get().server.available_user_domain_list();
let matched_domain = available_domains
.iter()
.filter(|d| input.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
if !input.contains('.') || split.is_some() {
let (short, domain) = split
.map(|(domain, short)| (short, domain))
.unwrap_or((input, domains.primary()));
let validated = validate_short_handle(short)?;
if !input.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input.strip_suffix(&format!(".{}", domain)).unwrap_or(input),
None => input,
};
let validated = validate_short_handle(handle_to_validate)?;
let domain = matched_domain
.or_else(|| available_domains.first())
.ok_or(HandleValidationError::NoHandleDomains)?;
let handle = Handle::new(format!("{}.{}", validated, domain))
.map_err(|_| HandleValidationError::InvalidSyntax)?;
match handle.has_disallowed_tld() {
@@ -235,9 +246,11 @@ pub fn domain_forms_valid_handles(domain: &str) -> bool {
}
pub fn warn_unusable_handle_domains() {
crate::handle::ServiceDomains::for_user_handles()
tranquil_config::get()
.server
.user_handle_domain_list()
.iter()
.filter(|domain| !domain_forms_valid_handles(domain.as_str()))
.filter(|domain| !domain_forms_valid_handles(domain))
.for_each(|domain| {
tracing::error!(
domain = %domain,
+2 -3
View File
@@ -5,9 +5,8 @@ pub use roles::{
CanAddControllers, CanControlAccounts, verify_can_add_controllers, verify_can_control_accounts,
};
pub use scopes::{
ADMIN_FULL_SCOPES, EDITOR_FULL_SCOPES, GrantCoverage, InvalidDelegationScopeError,
OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, grant_coverage,
intersect_scopes,
EDITOR_FULL_SCOPES, GrantCoverage, InvalidDelegationScopeError, OWNER_FULL_SCOPES,
SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, grant_coverage, intersect_scopes,
};
pub use tranquil_db_traits::DelegationActionType;
+3 -130
View File
@@ -14,15 +14,10 @@ pub struct ScopePreset {
pub scopes: &'static str,
}
pub const OWNER_FULL_SCOPES: &str = concat!(
"atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage ",
"transition:generic transition:chat.bsky transition:email"
);
pub const ADMIN_FULL_SCOPES: &str = "atproto repo:* blob:*/* rpc:* account:*?action=manage";
pub const OWNER_FULL_SCOPES: &str = "atproto repo:* blob:*/* identity:* account:*?action=manage";
pub const EDITOR_FULL_SCOPES: &str =
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/* rpc:*";
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
pub const SCOPE_PRESETS: &[ScopePreset] = &[
ScopePreset {
@@ -35,7 +30,7 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
name: "admin",
label: "Admin",
description: "Manage account settings, post content, upload media",
scopes: ADMIN_FULL_SCOPES,
scopes: "atproto repo:* blob:*/* account:*?action=manage",
},
ScopePreset {
name: "editor",
@@ -335,126 +330,4 @@ mod tests {
GrantCoverage::Narrowed("repo:io.atcr.manifest?action=create".to_string())
);
}
// Tracks all known scope prefixes
const GRANULAR_SCOPE_TAXONOMY: &[(&str, &str)] = &[
("repo", "repo:app.bsky.feed.post?action=create"),
("blob", "blob:image/png"),
("rpc", "rpc:app.bsky.actor.getProfile?aud=*"),
("account", "account:email?action=manage"),
("identity", "identity:handle"),
("transition:generic", "transition:generic"),
("transition:chat.bsky", "transition:chat.bsky"),
("transition:email", "transition:email"),
];
/// The taxonomy label a scope type must be represented by, or `None` for scope types
/// delegation never gates.
fn taxonomy_label(scope: &ParsedScope) -> Option<&'static str> {
match scope {
ParsedScope::Repo(_) => Some("repo"),
ParsedScope::Blob(_) => Some("blob"),
ParsedScope::Rpc(_) => Some("rpc"),
ParsedScope::Account(_) => Some("account"),
ParsedScope::Identity(_) => Some("identity"),
ParsedScope::TransitionGeneric => Some("transition:generic"),
ParsedScope::TransitionChat => Some("transition:chat.bsky"),
ParsedScope::TransitionEmail => Some("transition:email"),
ParsedScope::Atproto => None,
ParsedScope::Include(_) => None,
ParsedScope::Unknown(_) => None,
}
}
#[test]
fn test_taxonomy_entries_parse_to_the_scope_type_they_claim() {
GRANULAR_SCOPE_TAXONOMY.iter().for_each(|(label, scope)| {
assert_eq!(
taxonomy_label(&parse_scope(scope)),
Some(*label),
"taxonomy entry `{}` does not parse to a `{}` scope, so the reachability \
test is not actually exercising that scope type",
scope,
label
);
});
}
fn coverage_matrix() -> String {
GRANULAR_SCOPE_TAXONOMY
.iter()
.map(|(label, scope)| {
let granting: Vec<&str> = SCOPE_PRESETS
.iter()
.filter(|p| grant_coverage(p.scopes, scope) != GrantCoverage::Withheld)
.map(|p| p.name)
.collect();
match granting.is_empty() {
true => format!(" {:<9} ({}) -> NONE", label, scope),
false => format!(" {:<9} ({}) -> {}", label, scope, granting.join(", ")),
}
})
.collect::<Vec<String>>()
.join("\n")
}
#[test]
fn test_every_granular_scope_type_is_reachable_through_some_preset() {
let unreachable: Vec<&str> = GRANULAR_SCOPE_TAXONOMY
.iter()
.filter(|(_, scope)| {
SCOPE_PRESETS
.iter()
.all(|p| grant_coverage(p.scopes, scope) == GrantCoverage::Withheld)
})
.map(|(label, _)| *label)
.collect();
assert!(
unreachable.is_empty(),
"no delegation preset confers any `{}` scope, so delegated accounts cannot use \
that capability at all.\ncoverage by preset:\n{}",
unreachable.join("`, `"),
coverage_matrix()
);
}
#[test]
fn test_forbidden_rpc_wildcard_is_not_a_usable_grant() {
// `rpc:*?aud=*` wildcards both lxm and aud, which the spec forbids, so it parses to
// Unknown and confers nothing. A preset reaching for it to mean "all rpc" would look
// right and silently grant nothing -- `rpc:*` is the form that works.
assert_eq!(
grant_coverage("atproto rpc:*?aud=*", "rpc:app.bsky.actor.getProfile?aud=*"),
GrantCoverage::Withheld
);
assert_eq!(
grant_coverage("atproto rpc:*", "rpc:app.bsky.actor.getProfile?aud=*"),
GrantCoverage::Full
);
}
#[test]
fn test_forbidden_rpc_wildcard_request_stays_denied() {
assert_eq!(
grant_coverage("atproto rpc:*", "rpc:*?aud=*"),
GrantCoverage::Withheld
);
}
#[test]
fn test_grant_may_mix_transition_and_granular_scopes() {
assert!(ValidatedDelegationScope::new(OWNER_FULL_SCOPES).is_ok());
assert_eq!(
intersect_scopes("atproto transition:generic", OWNER_FULL_SCOPES),
"atproto transition:generic"
);
assert_eq!(
intersect_scopes(
"atproto repo:app.bsky.feed.post?action=create",
OWNER_FULL_SCOPES
),
"atproto repo:app.bsky.feed.post?action=create"
);
}
}
+9 -12
View File
@@ -61,21 +61,22 @@ pub struct DidResolver {
client: Client,
cache_ttl: Duration,
plc_directory_url: String,
fetch_policy: tranquil_types::ReachPolicy,
}
impl DidResolver {
pub fn new(cache: Arc<dyn Cache>) -> Self {
let cfg = tranquil_config::get();
let fetch_policy =
tranquil_types::ReachPolicy::from_private_fetch(cfg.server.allow_private_fetch);
let client = Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.pool_max_idle_per_host(10)
.redirect(tranquil_types::redirect_policy(fetch_policy))
.dns_resolver(tranquil_types::dns_guard(fetch_policy))
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.build()
.expect("failed to build DID resolver HTTP client");
@@ -86,7 +87,6 @@ impl DidResolver {
client,
cache_ttl: Duration::from_secs(cfg.plc.did_cache_ttl_secs),
plc_directory_url: cfg.plc.directory_url.clone(),
fetch_policy,
}
}
@@ -155,7 +155,7 @@ impl DidResolver {
&self,
did: &Did,
) -> Result<serde_json::Value, DidResolutionError> {
let url = build_did_web_url(did, self.fetch_policy)?;
let url = build_did_web_url(did)?;
debug!("Resolving did:web {} via {}", did, url);
@@ -214,10 +214,7 @@ impl DidResolver {
}
}
fn build_did_web_url(
did: &Did,
policy: tranquil_types::ReachPolicy,
) -> Result<String, DidResolutionError> {
fn build_did_web_url(did: &Did) -> Result<String, DidResolutionError> {
let host = did
.strip_prefix("did:web:")
.ok_or(DidResolutionError::InvalidDidWeb)?;
@@ -257,7 +254,7 @@ fn build_did_web_url(
if tranquil_types::url_reach(&url) == Some(tranquil_types::HostReach::Loopback) {
let _ = url.set_scheme("http");
}
match tranquil_types::url_reach_permits(&url, policy) {
match tranquil_types::url_reach_permits(&url, tranquil_types::ReachPolicy::DEBUG_LOOPBACK) {
true => Ok(url.to_string()),
false => Err(DidResolutionError::DidWebHostRejected(host)),
}
+16 -133
View File
@@ -3,16 +3,8 @@ pub mod reserved;
use crate::types::{Did, Handle};
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use std::sync::LazyLock;
use thiserror::Error;
pub use tranquil_types::Domain;
static HOSTNAME_DOMAIN: LazyLock<Domain> = LazyLock::new(|| {
Domain::new(tranquil_config::get().server.hostname_without_port())
.expect("server.hostname is validated at config load")
});
#[derive(Error, Debug)]
pub enum HandleResolutionError {
#[error("DNS lookup failed: {0}")]
@@ -93,137 +85,28 @@ pub async fn verify_handle_ownership(
}
}
#[derive(Clone, Copy)]
pub struct ServiceDomains<'a> {
user_domains: &'a [Domain],
hostname: &'a Domain,
serve_hostname: bool,
}
impl ServiceDomains<'static> {
pub fn for_user_handles() -> Self {
Self::from_config(false)
}
pub fn served() -> Self {
Self::from_config(true)
}
fn from_config(serve_hostname: bool) -> Self {
let server = &tranquil_config::get().server;
Self {
user_domains: server.user_handle_domains.as_deref().unwrap_or_default(),
hostname: &HOSTNAME_DOMAIN,
serve_hostname,
}
}
}
impl<'a> ServiceDomains<'a> {
pub fn iter(&self) -> impl Iterator<Item = &'a Domain> {
let hostname = (self.serve_hostname || self.user_domains.is_empty())
.then_some(self.hostname)
.filter(|h| !self.user_domains.contains(h));
self.user_domains.iter().chain(hostname)
}
pub fn primary(&self) -> &'a Domain {
self.user_domains.first().unwrap_or(self.hostname)
}
pub fn contains(&self, name: &str) -> bool {
self.iter().any(|d| d.eq_name(name))
}
pub fn split_handle<'h>(&self, handle: &'h str) -> Option<(&'a Domain, &'h str)> {
self.iter()
.filter_map(|d| d.strip_from(handle).map(|short| (d, short)))
.max_by_key(|(d, _)| d.as_str().len())
pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
if !handle.contains('.') {
return true;
}
let service_domains = tranquil_config::try_get()
.map(|c| c.server.user_handle_domain_list())
.unwrap_or_else(|| vec![hostname.to_string()]);
service_domains
.iter()
.any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
}
#[cfg(test)]
mod tests {
use super::{Domain, ServiceDomains};
use std::sync::LazyLock;
static HOST: LazyLock<Domain> = LazyLock::new(|| "pds.oyster.cafe".parse().unwrap());
fn domains(user_domains: &[Domain], serve_hostname: bool) -> ServiceDomains<'_> {
ServiceDomains {
user_domains,
hostname: &HOST,
serve_hostname,
}
}
fn owned(list: &[&str]) -> Vec<Domain> {
list.iter().map(|d| d.parse().unwrap()).collect()
}
use super::*;
#[test]
fn thostname_until_domains_are_configured() {
assert!(domains(&[], false).contains("pds.oyster.cafe"));
assert_eq!(domains(&[], false).primary(), "pds.oyster.cafe");
let configured = owned(&["oyster.cafe"]);
assert!(!domains(&configured, false).contains("pds.oyster.cafe"));
assert!(domains(&configured, false).contains("oyster.cafe"));
}
#[test]
fn served_set_covers_hostname_and_handle_domains() {
let configured = owned(&["oyster.cafe"]);
assert!(domains(&configured, true).contains("pds.oyster.cafe"));
assert!(domains(&configured, true).contains("oyster.cafe"));
}
#[test]
fn hostname_in_list_is_yielded_once() {
let configured = owned(&["pds.oyster.cafe", "oyster.cafe"]);
let served: Vec<&str> = domains(&configured, true)
.iter()
.map(Domain::as_str)
.collect();
assert_eq!(served, ["pds.oyster.cafe", "oyster.cafe"]);
let configured = owned(&["PDS.Oyster.Cafe"]);
let served: Vec<&str> = domains(&configured, true)
.iter()
.map(Domain::as_str)
.collect();
assert_eq!(served, ["pds.oyster.cafe"]);
}
#[test]
fn matching_case_insensitive() {
let configured = owned(&["oyster.cafe"]);
assert!(domains(&configured, false).contains("Oyster.Cafe"));
let (domain, short) = domains(&configured, false)
.split_handle("NEL.OYSTER.CAFE")
.unwrap();
assert_eq!(domain, "oyster.cafe");
assert_eq!(short, "NEL");
}
#[test]
fn longest_matching_domain_wins() {
let configured = owned(&["oyster.cafe", "pets.oyster.cafe"]);
let (domain, short) = domains(&configured, false)
.split_handle("nel.pets.oyster.cafe")
.unwrap();
assert_eq!(domain, "pets.oyster.cafe");
assert_eq!(short, "nel");
}
#[test]
fn split_handle_requires_a_dot() {
let configured = owned(&["oyster.cafe"]);
assert_eq!(
domains(&configured, false).split_handle("oyster.cafe"),
None
);
assert_eq!(
domains(&configured, false).split_handle("notoyster.cafe"),
None
);
fn test_is_service_domain_handle() {
assert!(is_service_domain_handle("nel.oyster.cafe", "oyster.cafe"));
assert!(is_service_domain_handle("oyster.cafe", "oyster.cafe"));
assert!(is_service_domain_handle("myhandle", "oyster.cafe"));
assert!(!is_service_domain_handle("lyna.nel.pet", "oyster.cafe"));
assert!(!is_service_domain_handle("myhandle.xyz", "oyster.cafe"));
}
}
+14 -1
View File
@@ -106,7 +106,20 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers(AllowHeaders::mirror_request())
.allow_headers(AllowHeaders::list(
[
http::header::AUTHORIZATION,
http::header::CONTENT_TYPE,
http::header::CONTENT_ENCODING,
http::header::ACCEPT_ENCODING,
http::header::USER_AGENT,
util::HEADER_DPOP,
util::HEADER_ATPROTO_PROXY,
util::HEADER_ATPROTO_ACCEPT_LABELERS,
]
.into_iter()
.chain(util::CORS_BSKY_ALLOW_HEADERS),
))
.expose_headers([
http::header::WWW_AUTHENTICATE,
util::HEADER_DPOP_NONCE,
+8 -6
View File
@@ -10,9 +10,7 @@ use tranquil_oauth::{
AuthorizationServerMetadata, ClientMetadata, compute_es256_jkt, compute_pkce_challenge,
create_dpop_proof,
};
use tranquil_types::{
AuthorizationCode, ClientId, CrossPdsState, Did, Issuer, PdsUrl, ReachPolicy,
};
use tranquil_types::{AuthorizationCode, ClientId, CrossPdsState, Did, Issuer, PdsUrl};
use crate::cache::Cache;
@@ -70,12 +68,16 @@ pub struct CrossPdsOAuthClient {
}
impl CrossPdsOAuthClient {
pub fn new(cache: Arc<dyn Cache>, fetch_policy: ReachPolicy) -> Self {
pub fn new(cache: Arc<dyn Cache>) -> Self {
let http = Client::builder()
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(5))
.redirect(tranquil_types::redirect_policy(fetch_policy))
.dns_resolver(tranquil_types::dns_guard(fetch_policy))
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::GlobalOnly,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::GlobalOnly,
))
.build()
.expect("failed to build cross-PDS OAuth HTTP client");
Self { http, cache }
+6 -5
View File
@@ -187,16 +187,17 @@ impl PlcClient {
});
let timeout_secs = cfg.map_or(10, |c| c.plc.timeout_secs);
let connect_timeout_secs = cfg.map_or(5, |c| c.plc.connect_timeout_secs);
let fetch_policy = tranquil_types::ReachPolicy::from_private_fetch(
cfg.is_some_and(|c| c.server.allow_private_fetch),
);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(connect_timeout_secs))
.pool_max_idle_per_host(5)
.pool_idle_timeout(Duration::from_secs(90))
.redirect(tranquil_types::redirect_policy(fetch_policy))
.dns_resolver(tranquil_types::dns_guard(fetch_policy))
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.build()
.expect("failed to build PLC directory HTTP client");
Self {
+32 -75
View File
@@ -225,17 +225,10 @@ struct CacheBound {
impl CacheBound {
fn new(cache: &Arc<dyn Cache>, sso_config: &'static SsoConfig) -> Self {
tranquil_lexicon::LexiconRegistry::global().set_shared_cache(cache.clone());
let fetch_policy = tranquil_types::ReachPolicy::from_private_fetch(
tranquil_config::get().server.allow_private_fetch,
);
Self {
did_resolver: Arc::new(DidResolver::new(cache.clone())),
cross_pds_oauth: Arc::new(CrossPdsOAuthClient::new(cache.clone(), fetch_policy)),
client_metadata_cache: ClientMetadataCache::new(
cache.clone(),
CLIENT_METADATA_TTL,
fetch_policy,
),
cross_pds_oauth: Arc::new(CrossPdsOAuthClient::new(cache.clone())),
client_metadata_cache: ClientMetadataCache::new(cache.clone(), CLIENT_METADATA_TTL),
sso_manager: SsoManager::from_config(sso_config, cache.clone()),
}
}
@@ -521,78 +514,42 @@ struct TranquilStoreWiring {
}
fn migrate_delegation_preset_scopes(metastore: &tranquil_store::metastore::Metastore) {
const V1_MARKER: &str = "migration:delegation_preset_scopes_v1";
const V1_LEGACY_OWNER: &str = "atproto";
const V1_LEGACY_EDITOR: &str =
const MARKER_KEY: &str = "migration:delegation_preset_scopes_v1";
const LEGACY_EDITOR_SCOPES: &str =
"repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
// v2 adds `rpc:*` to the writing presets
// Without it delegated sessions can't hold rpc scopes
const V2_MARKER: &str = "migration:delegation_preset_scopes_v2";
const V2_LEGACY_OWNER: &str = "atproto repo:* blob:*/* identity:* account:*?action=manage";
const V2_LEGACY_ADMIN: &str = "atproto repo:* blob:*/* account:*?action=manage";
const V2_LEGACY_EDITOR: &str =
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
// v3 adds the `transition:` scopes to the owner preset
// Without them an owner-level delegation withholds all transition scopes
const V3_MARKER: &str = "migration:delegation_preset_scopes_v3";
const V3_LEGACY_OWNER: &str =
"atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage";
let passes: [(&str, &[(&str, &str)]); 3] = [
(
V1_MARKER,
&[
(V1_LEGACY_OWNER, crate::delegation::OWNER_FULL_SCOPES),
(V1_LEGACY_EDITOR, crate::delegation::EDITOR_FULL_SCOPES),
],
),
(
V2_MARKER,
&[
(V2_LEGACY_OWNER, crate::delegation::OWNER_FULL_SCOPES),
(V2_LEGACY_ADMIN, crate::delegation::ADMIN_FULL_SCOPES),
(V2_LEGACY_EDITOR, crate::delegation::EDITOR_FULL_SCOPES),
],
),
(
V3_MARKER,
&[(V3_LEGACY_OWNER, crate::delegation::OWNER_FULL_SCOPES)],
),
];
let infra = metastore.infra_ops();
if infra.get_server_config(MARKER_KEY).ok().flatten().is_some() {
return;
}
let ops = metastore.delegation_ops();
for (marker, remaps) in passes {
if infra.get_server_config(marker).ok().flatten().is_some() {
continue;
}
let mut migrated = 0usize;
for (from, to) in remaps {
match ops.remap_grant_scopes(from, to) {
Ok(n) => migrated += n,
Err(e) => {
tracing::error!(error = ?e, marker, from, "delegation scope migration failed, will retry on next start");
return;
}
}
}
if migrated > 0 {
tracing::info!(
marker,
migrated,
"upgraded legacy delegation grants to preset scopes"
);
}
if let Err(e) = infra.upsert_server_config(marker, "done") {
tracing::error!(error = ?e, marker, "failed to record delegation scope migration marker, will retry");
let owners = match ops.remap_grant_scopes("atproto", crate::delegation::OWNER_FULL_SCOPES) {
Ok(n) => n,
Err(e) => {
tracing::error!(error = ?e, "delegation owner-scope migration failed, will retry on next start");
return;
}
};
let editors = match ops
.remap_grant_scopes(LEGACY_EDITOR_SCOPES, crate::delegation::EDITOR_FULL_SCOPES)
{
Ok(n) => n,
Err(e) => {
tracing::error!(error = ?e, "delegation editor-scope migration failed, will retry on next start");
return;
}
};
if owners + editors > 0 {
tracing::info!(
owners,
editors,
"upgraded legacy delegation grants to preset scopes"
);
}
if let Err(e) = infra.upsert_server_config(MARKER_KEY, "done") {
tracing::error!(error = ?e, "failed to record delegation scope migration marker, will retry");
}
}
+4
View File
@@ -89,6 +89,10 @@ 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");
#[cfg(feature = "bsky-support")]
pub const CORS_BSKY_ALLOW_HEADERS: [HeaderName; 1] = [HEADER_X_BSKY_TOPICS];
#[cfg(not(feature = "bsky-support"))]
pub const CORS_BSKY_ALLOW_HEADERS: [HeaderName; 0] = [];
pub fn get_header_str(
headers: &HeaderMap,
-113
View File
@@ -1,113 +0,0 @@
mod common;
use common::*;
use futures::StreamExt;
use reqwest::StatusCode;
use serde_json::{Value, json};
#[ctor::ctor]
fn enable_on_demand_tls() {
unsafe {
std::env::set_var("ENABLE_CADDY_ON_DEMAND_TLS", "true");
std::env::set_var("PDS_USER_HANDLE_DOMAINS", "handles.pds.test");
}
}
async fn create_hosted_account() -> String {
let client = client();
let short_handle = format!("caddy{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let payload = json!({
"handle": short_handle,
"email": format!("{}@oyster.cafe", short_handle),
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await
.expect("failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res
.json()
.await
.expect("createAccount response wasn't JSON");
body["handle"]
.as_str()
.expect("createAccount didn't return a handle")
.to_string()
}
async fn ask(client: &reqwest::Client, domain: &str) -> StatusCode {
client
.get(format!("{}/.well-known/caddy/ask", base_url().await))
.query(&[("domain", domain)])
.send()
.await
.expect("failed to query ask endpoint")
.status()
}
#[tokio::test]
async fn test_caddy_ask_allows_hosted_handle() {
let client = client();
let handle = create_hosted_account().await;
assert_eq!(ask(&client, &handle).await, StatusCode::OK);
assert_eq!(ask(&client, &handle.to_uppercase()).await, StatusCode::OK);
assert_eq!(ask(&client, &format!("{handle}.")).await, StatusCode::OK);
}
#[tokio::test]
async fn test_caddy_ask_denies_unhosted_and_invalid_domains() {
let client = client();
let unknown = format!("ghost-{}.handles.pds.test", uuid::Uuid::new_v4().simple());
assert_eq!(ask(&client, &unknown).await, StatusCode::NOT_FOUND);
assert_eq!(ask(&client, "nel.pet").await, StatusCode::NOT_FOUND);
assert_eq!(
ask(&client, "!!not-a-handle").await,
StatusCode::BAD_REQUEST
);
assert_eq!(ask(&client, "").await, StatusCode::BAD_REQUEST);
let res = client
.get(format!("{}/.well-known/caddy/ask", base_url().await))
.send()
.await
.expect("failed to query ask endpoint");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_caddy_ask_allows_handle_domain_apexes() {
let client = client();
base_url().await;
futures::stream::iter(
tranquil_config::get()
.server
.user_handle_domains
.iter()
.flatten(),
)
.for_each(|domain| async {
assert_eq!(ask(&client, domain.as_str()).await, StatusCode::OK);
})
.await;
}
#[tokio::test]
async fn test_caddy_ask_allows_the_pds_hostname_beside_handle_domains() {
let client = client();
base_url().await;
let cfg = tranquil_config::get();
let hostname = cfg.server.hostname_without_port();
assert!(
!cfg.server
.user_handle_domains
.iter()
.flatten()
.any(|d| d == hostname),
"this test only means something if hostname is outside the handle domains"
);
assert_eq!(ask(&client, hostname).await, StatusCode::OK);
}
+2 -3
View File
@@ -14,7 +14,7 @@ use tranquil_pds::auth::{
get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token,
verify_token,
};
use tranquil_types::{Did, DidRef, Nsid};
use tranquil_types::{Did, Nsid};
fn generate_user_key() -> Vec<u8> {
let secret_key = SecretKey::random(&mut OsRng);
@@ -169,9 +169,8 @@ fn test_token_type_confusion() {
let service_token = create_service_token(
&did,
&DidRef::new("did:web:nel.pet").expect("valid DID reference"),
&Did::new("did:web:nel.pet").expect("valid DID"),
Some(&Nsid::new("cafe.oyster.method").expect("valid NSID")),
None,
&key_bytes,
)
.unwrap();
-57
View File
@@ -1270,63 +1270,6 @@ async fn test_granular_scope_rpc_specific_method() {
);
}
#[tokio::test]
async fn test_granular_scope_rpc_aud_with_service_id() {
let url = base_url().await;
let http_client = client();
let (token, _, _) = get_oauth_token_with_scope(
"rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app#bsky_appview",
)
.await;
let allowed_res = http_client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
.bearer_auth(&token)
.query(&[
("aud", "did:web:api.bsky.app#bsky_appview"),
("lxm", "app.bsky.feed.getTimeline"),
])
.send()
.await
.unwrap();
assert_eq!(
allowed_res.status(),
StatusCode::OK,
"the granted service id must cover a request naming it"
);
let body: Value = allowed_res.json().await.unwrap();
let service_token = body["token"].as_str().unwrap();
let payload = service_token.split('.').nth(1).unwrap();
let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
assert_eq!(
claims["aud"], "did:web:api.bsky.app#bsky_appview",
"the service id must reach the signed claim even on the granular scope path"
);
for (aud, reason) in [
(
"did:web:api.bsky.app#atproto_labeler",
"a scope for the appview must not mint tokens for the labeler on the same DID",
),
(
"did:web:api.bsky.app",
"a scope for one service must not widen to the whole DID",
),
(
"did:web:other.example#bsky_appview",
"a service id must not smuggle in a different audience",
),
] {
let blocked_res = http_client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
.bearer_auth(&token)
.query(&[("aud", aud), ("lxm", "app.bsky.feed.getTimeline")])
.send()
.await
.unwrap();
assert_eq!(blocked_res.status(), StatusCode::FORBIDDEN, "{reason}");
}
}
#[tokio::test]
async fn test_oauth_metadata_includes_prompt_values_supported() {
let url = base_url().await;
@@ -280,11 +280,10 @@ async fn test_delegated_consent_marks_restricted_scopes() {
seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
let scope = format!("atproto include:{}", PERMISSION_SET_NSID);
let (_session, consent_body, _mock) = create_delegated_session_with_grant(
let (_session, consent_body, _mock) = create_delegated_session_with_scope(
"psr",
"https://example.com/permset-restricted-callback",
&scope,
"atproto repo:*",
)
.await;
@@ -328,53 +327,7 @@ async fn test_delegated_consent_marks_restricted_scopes() {
assert_eq!(
rpc["restricted"].as_bool(),
Some(true),
"rpc scope is not conferred by a repo-only grant and must be restricted"
);
}
#[tokio::test]
async fn test_delegated_owner_grant_confers_rpc_scopes() {
seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
let scope = format!("atproto include:{}", PERMISSION_SET_NSID);
let (_session, consent_body, _mock) = create_delegated_session_with_scope(
"pso",
"https://example.com/permset-owner-rpc-callback",
&scope,
)
.await;
let set_entry = consent_body["permission_sets"]
.as_array()
.and_then(|sets| {
sets.iter()
.find(|s| s["nsid"].as_str() == Some(PERMISSION_SET_NSID))
})
.unwrap_or_else(|| {
panic!(
"expected a permission_sets entry for '{}'. Got: {:?}",
PERMISSION_SET_NSID, consent_body
)
});
let expanded = set_entry["expanded"]
.as_array()
.expect("permission_sets entry should have an expanded array");
let rpc = expanded
.iter()
.find(|s| s["scope"].as_str() == Some("rpc:io.atcr.getManifest?aud=*"))
.expect("expanded[] should list the rpc scope");
assert_eq!(
rpc["restricted"].as_bool(),
Some(false),
"the OWNER grant includes rpc:* and must confer rpc scopes"
);
assert_eq!(
set_entry["restricted"].as_bool(),
Some(false),
"a fully-covered set must not be flagged restricted"
"rpc scope is not conferred by the OWNER grant and must be restricted"
);
}
@@ -181,52 +181,6 @@ fn test_permissions_rpc_lxm_wildcard_prefix() {
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.actor.getProfile")));
}
#[test]
fn test_permissions_rpc_aud_service_id_must_match_verbatim() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.*?aud=did:web:api.bsky.app"));
assert!(
perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")),
"the granted audience must cover itself"
);
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"a bare DID grants nothing to the services listed under it"
);
assert!(
!perms.allows_rpc(
"did:web:other.example#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"a service id must not smuggle in a different audience"
);
let fragment_scope = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.*?aud=did:web:api.bsky.app%23bsky_appview",
));
assert!(
fragment_scope.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"the granted service id must cover itself"
);
assert!(
!fragment_scope.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")),
"a scope granted for one service must not widen to the whole DID"
);
assert!(
!fragment_scope.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getTimeline")
),
"the appview and the labeler are different audiences even on one DID"
);
}
#[test]
fn test_delegation_intersect_mismatched_params_empty() {
let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete");
-40
View File
@@ -132,46 +132,6 @@ async fn test_service_auth() {
let lxm_payload = URL_SAFE_NO_PAD.decode(lxm_parts[1]).unwrap();
let lxm_claims: Value = serde_json::from_slice(&lxm_payload).unwrap();
assert_eq!(lxm_claims["lxm"], "com.atproto.repo.getRecord");
let fragment_res = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[
("aud", "did:web:example.com#colibri_appview"),
("lxm", "com.atproto.repo.getRecord"),
])
.send()
.await
.unwrap();
assert_eq!(fragment_res.status(), StatusCode::OK);
let fragment_body: Value = fragment_res.json().await.unwrap();
let fragment_token = fragment_body["token"].as_str().unwrap();
let fragment_parts: Vec<&str> = fragment_token.split('.').collect();
let fragment_payload = URL_SAFE_NO_PAD.decode(fragment_parts[1]).unwrap();
let fragment_claims: Value = serde_json::from_slice(&fragment_payload).unwrap();
assert_eq!(
fragment_claims["aud"], "did:web:example.com#colibri_appview",
"the service id must survive into the signed claim so the receiver can match it \
against its own DID document"
);
let empty_fragment = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[("aud", "did:web:example.com#")])
.send()
.await
.unwrap();
assert_eq!(empty_fragment.status(), StatusCode::BAD_REQUEST);
let double_fragment = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[("aud", "did:web:example.com#a#b")])
.send()
.await
.unwrap();
assert_eq!(double_fragment.status(), StatusCode::BAD_REQUEST);
let unauth = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.query(&[("aud", "did:web:example.com")])
+1 -1
View File
@@ -48,7 +48,7 @@ pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
category: ScopeCategory::Transition,
required: false,
description: "Generic transition scope for compatibility",
display_name: "Generic Access",
display_name: "Transition Access",
},
ScopeDefinition {
scope: "transition:chat.bsky",
+1 -1
View File
@@ -19,4 +19,4 @@ pub use permission_set::{
ExpansionOutcome, FailedSet, FetchedSet, ResolveFailure, ResolvedSetGroup, ScopeExpansionError,
fetch_and_expand, parse_include_scope,
};
pub use permissions::{ScopePermissions, superseded_by_transition_generic};
pub use permissions::ScopePermissions;
+45 -233
View File
@@ -43,26 +43,7 @@ impl ScopePermissions {
has_transition_email,
}
}
}
/// Whether holding `transition:generic` makes `scope` redundant.
pub fn superseded_by_transition_generic(scope: &ParsedScope) -> bool {
match scope {
ParsedScope::Repo(_) | ParsedScope::Blob(_) => true,
ParsedScope::Rpc(rpc) => !rpc
.lxm
.as_deref()
.is_some_and(|lxm| lxm == "*" || lxm.starts_with("chat.bsky.")),
ParsedScope::Account(_)
| ParsedScope::Identity(_)
| ParsedScope::TransitionEmail
| ParsedScope::TransitionChat => false,
ParsedScope::Include(_) => false,
ParsedScope::TransitionGeneric | ParsedScope::Atproto | ParsedScope::Unknown(_) => false,
}
}
impl ScopePermissions {
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.contains(scope)
}
@@ -177,19 +158,26 @@ impl ScopePermissions {
}
pub fn assert_rpc(&self, aud: &str, lxm: &Nsid) -> Result<(), ScopeError> {
let is_chat = lxm.starts_with("chat.bsky.");
if lxm.starts_with("chat.bsky.") {
if self.has_transition_chat {
return Ok(());
}
if self.has_transition_generic && !self.has_transition_chat {
return Err(ScopeError::InsufficientScope {
required: "transition:chat.bsky".to_string(),
message: format!(
"Chat access requires transition:chat.bsky scope to call {}",
lxm
),
});
}
}
if is_chat && self.has_transition_chat {
if self.has_transition_generic {
return Ok(());
}
// `transition:generic` covers every lexicon except chat. Note it does not *block* chat:
// holding it must never remove access a granular `rpc:chat.bsky.*` scope would grant on
// its own, so chat requests fall through to the granular check below rather than
// failing here.
if self.has_transition_generic && !is_chat {
return Ok(());
}
let aud_base = aud.split('#').next().unwrap_or(aud);
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
let lxm_matches = match &rpc_scope.lxm {
@@ -205,31 +193,23 @@ impl ScopePermissions {
let aud_matches = match &rpc_scope.aud {
None => true,
Some(scope_aud) if scope_aud == "*" => true,
Some(scope_aud) => scope_aud == aud,
Some(scope_aud) => {
let scope_aud_base = scope_aud.split('#').next().unwrap_or(scope_aud);
scope_aud_base == aud_base
}
};
lxm_matches && aud_matches
});
if has_permission {
return Ok(());
}
// Point a caller holding only `transition:generic` at the scope it actually needs,
// rather than at a granular rpc scope it probably did not mean to request.
Err(match is_chat && self.has_transition_generic {
true => ScopeError::InsufficientScope {
required: "transition:chat.bsky".to_string(),
message: format!(
"Chat access requires transition:chat.bsky scope to call {}",
lxm
),
},
false => ScopeError::InsufficientScope {
Ok(())
} else {
Err(ScopeError::InsufficientScope {
required: format!("rpc:{}?aud={}", lxm, aud),
message: format!("Insufficient scope to call {} on {}", lxm, aud),
},
})
})
}
}
pub fn assert_account(
@@ -237,6 +217,10 @@ impl ScopePermissions {
attr: AccountAttr,
action: AccountAction,
) -> Result<(), ScopeError> {
if self.has_transition_generic {
return Ok(());
}
if attr == AccountAttr::Email && action == AccountAction::Read && self.has_transition_email
{
return Ok(());
@@ -266,7 +250,8 @@ impl ScopePermissions {
}
pub fn allows_email_read(&self) -> bool {
self.has_transition_email
self.has_transition_generic
|| self.has_transition_email
|| self
.find_account_scopes()
.any(|a| a.attr == AccountAttr::Email || a.attr == AccountAttr::Wildcard)
@@ -289,6 +274,10 @@ impl ScopePermissions {
}
pub fn assert_identity(&self, attr: IdentityAttr) -> Result<(), ScopeError> {
if self.has_transition_generic {
return Ok(());
}
let has_permission = self.find_identity_scopes().any(|identity_scope| {
identity_scope.attr == IdentityAttr::Wildcard || identity_scope.attr == attr
});
@@ -352,7 +341,6 @@ impl Default for ScopePermissions {
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_scope;
fn c(s: &str) -> Nsid {
s.parse().unwrap()
@@ -529,10 +517,10 @@ mod tests {
}
#[test]
fn test_transition_generic_does_not_grant_identity() {
fn test_transition_generic_grants_identity() {
let perms = ScopePermissions::from_scope_string(Some("transition:generic"));
assert!(!perms.allows_identity(IdentityAttr::Handle));
assert!(!perms.allows_identity(IdentityAttr::Wildcard));
assert!(perms.allows_identity(IdentityAttr::Handle));
assert!(perms.allows_identity(IdentityAttr::Wildcard));
}
#[test]
@@ -570,204 +558,28 @@ mod tests {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app#bsky_appview",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
));
assert!(
!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")),
"a scope naming one service must not cover the whole DID"
);
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getAuthorFeed")
),
"a scope naming one service must not cover a sibling service on the same DID"
);
assert!(perms.allows_rpc(
"did:web:api.bsky.app#other_service",
&c("app.bsky.feed.getAuthorFeed")
));
assert!(!perms.allows_rpc("did:web:other.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_rpc_scope_without_fragment_does_not_cover_service_ids() {
fn test_rpc_scope_without_fragment_matches_with_fragment() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
),
"the audience is compared verbatim, so a bare DID grants nothing to its services"
);
}
#[test]
fn test_rpc_scope_aud_wildcard_covers_service_ids() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.getAuthorFeed?aud=*"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
));
}
#[test]
fn transition_generic_supersedes_granular_scopes() {
for scope in [
"repo:app.bsky.feed.post?action=create",
"blob:image/png",
"rpc:app.bsky.actor.getProfile?aud=*",
] {
assert!(
superseded_by_transition_generic(&parse_scope(scope)),
"{scope} should be superseded by transition:generic"
);
}
}
#[test]
fn transition_generic_does_not_supersede_chat() {
// assert_rpc rejects chat.bsky.* when transition:generic is held without
// transition:chat.bsky, so neither the transition scope nor an rpc scope that
// could reach a chat lexicon is covered by it.
for scope in [
"transition:chat.bsky",
"rpc:chat.bsky.convo.sendMessage?aud=*",
"rpc:*?aud=did:web:api.bsky.app",
"account:email?action=manage",
"account:email?action=read",
"account:status?action=read",
"identity:handle",
"identity:*",
"transition:email",
] {
assert!(
!superseded_by_transition_generic(&parse_scope(scope)),
"{scope} must not be treated as superseded"
);
}
}
#[test]
fn transition_generic_does_not_supersede_itself_or_baseline() {
assert!(!superseded_by_transition_generic(&parse_scope(
"transition:generic"
)));
assert!(!superseded_by_transition_generic(&parse_scope("atproto")));
}
#[test]
fn superseded_matches_enforcement_for_chat_and_feed() {
// Cross-check against ScopePermissions so the two cannot drift apart.
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
let feed = Nsid::new("app.bsky.feed.getTimeline").unwrap();
let chat = Nsid::new("chat.bsky.convo.sendMessage").unwrap();
assert!(perms.allows_rpc("did:web:api.bsky.app", &feed));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &chat));
}
#[test]
fn granular_chat_rpc_works_without_transition_generic() {
// Baseline for the test below: on its own, a granular chat rpc scope grants chat.
let perms = ScopePermissions::from_scope_string(Some(
"atproto rpc:chat.bsky.convo.sendMessage?aud=*",
));
assert!(perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.sendMessage")));
}
#[test]
fn transition_generic_does_not_revoke_granular_chat_rpc() {
// Adding a broader scope must never remove access. transition:generic does not cover
// chat lexicons, but it must not stop a granular chat rpc scope from doing so either.
let perms = ScopePermissions::from_scope_string(Some(
"atproto transition:generic rpc:chat.bsky.convo.sendMessage?aud=*",
));
assert!(perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.sendMessage")));
// ...and still grants everything else it covers.
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn transition_generic_does_not_widen_granular_chat_rpc() {
// The granular scope grants exactly one chat lexicon; transition:generic must not be
// read as covering the rest of chat.
let perms = ScopePermissions::from_scope_string(Some(
"atproto transition:generic rpc:chat.bsky.convo.sendMessage?aud=*",
));
assert!(!perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.deleteMessage")));
}
#[test]
fn chat_denial_still_names_the_scope_the_caller_needs() {
// transition:generic alone: the useful advice is "ask for transition:chat.bsky",
// not "ask for rpc:chat.bsky.convo.listConvos".
let generic = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
let err = generic
.assert_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.listConvos"))
.expect_err("chat must be denied without transition:chat.bsky");
match err {
ScopeError::InsufficientScope { required, .. } => {
assert_eq!(required, "transition:chat.bsky");
}
other => panic!("unexpected error: {other:?}"),
}
// Without transition:generic the granular scope is the right thing to name.
let bare = ScopePermissions::from_scope_string(Some("atproto"));
let err = bare
.assert_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.listConvos"))
.expect_err("chat must be denied with no rpc scope at all");
match err {
ScopeError::InsufficientScope { required, .. } => {
assert!(required.starts_with("rpc:chat.bsky.convo.listConvos"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn transition_generic_does_not_grant_account_management() {
// "no account management actions: change handle, change email, delete or deactivate
// account, migrate account" -- atproto OAuth spec.
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
assert!(!perms.allows_account(AccountAttr::Repo, AccountAction::Manage));
assert!(!perms.allows_account(AccountAttr::Status, AccountAction::Manage));
}
#[test]
fn transition_generic_does_not_grant_email_read() {
// Reading the account email is what transition:email is for.
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
assert!(!perms.allows_email_read());
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Read));
}
#[test]
fn granular_scopes_still_grant_alongside_transition_generic() {
// Removing the short-circuit must not stop an explicitly granted scope from working.
let perms = ScopePermissions::from_scope_string(Some(
"atproto transition:generic account:email?action=manage identity:handle",
));
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Manage));
assert!(perms.allows_identity(IdentityAttr::Handle));
let with_email = ScopePermissions::from_scope_string(Some(
"atproto transition:generic transition:email",
));
assert!(with_email.allows_email_read());
}
#[test]
fn transition_generic_still_grants_what_the_spec_says_it_does() {
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
assert!(perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
assert!(perms.allows_repo(RepoAction::Delete, &c("app.bsky.feed.post")));
assert!(perms.allows_blob("image/png"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
}
+2 -5
View File
@@ -77,12 +77,9 @@ async fn main() -> ExitCode {
}
config
.server
.user_handle_domains
.user_handle_domain_list()
.iter()
.flatten()
.filter(|d| {
!tranquil_pds::api::validation::domain_forms_valid_handles(d.as_str())
})
.filter(|d| !tranquil_pds::api::validation::domain_forms_valid_handles(d))
.for_each(|d| {
eprintln!(
"account creation under handle domain {d} will be rejected because its TLD is reserved"
+4 -12
View File
@@ -344,12 +344,8 @@ fn verify_backup_detects_checksum_mismatch() {
.unwrap();
let manifest = read_manifest(backup_dir.path()).unwrap();
let target = manifest
.files
.iter()
.find(|f| f.size > 0)
.expect("backup manifest must list a file with content");
let file_path = backup_dir.path().join(&target.path);
let first_file = &manifest.files[0];
let file_path = backup_dir.path().join(&first_file.path);
let mut data = std::fs::read(&file_path).unwrap();
data.iter_mut().take(8).for_each(|b| *b ^= 0xFF);
std::fs::write(&file_path, &data).unwrap();
@@ -905,12 +901,8 @@ fn restore_fails_cleanly_on_corrupted_backup() {
.create_backup(backup_dir.path())
.unwrap();
let target = manifest
.files
.iter()
.find(|f| f.size > 0)
.expect("backup manifest must list a file with content");
let file_path = backup_dir.path().join(&target.path);
let first_file = &manifest.files[0];
let file_path = backup_dir.path().join(&first_file.path);
let mut data = std::fs::read(&file_path).unwrap();
data.iter_mut().take(16).for_each(|b| *b ^= 0xFF);
std::fs::write(&file_path, &data).unwrap();
-205
View File
@@ -225,52 +225,6 @@ impl Did {
}
}
const DID_REF_MAX_LEN: usize = 2048;
const SERVICE_ID_MAX_LEN: usize = 128;
const fn is_pchar(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')
}
fn is_service_id(s: &str) -> bool {
!s.is_empty() && s.len() <= SERVICE_ID_MAX_LEN && s.bytes().all(is_pchar)
}
validated_string_newtype! {
pub struct DidRef;
error = DidRefError;
label = "DID reference";
validator = |s| {
if s.len() > DID_REF_MAX_LEN {
return Err(());
}
match s.split_once('#') {
None => jacquard_common::types::string::Did::new(s)
.map(|v| v.as_str().to_owned())
.map_err(|_| ()),
Some((did, service_id)) => {
if !is_service_id(service_id) {
return Err(());
}
let base = jacquard_common::types::string::Did::new(did).map_err(|_| ())?;
Ok(format!("{}#{}", base.as_str(), service_id))
}
}
};
}
impl From<Did> for DidRef {
fn from(did: Did) -> Self {
Self(did.0)
}
}
impl From<&Did> for DidRef {
fn from(did: &Did) -> Self {
Self(did.0.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
@@ -330,38 +284,6 @@ pub enum HandleError {
Invalid(String),
}
validated_string_newtype! {
pub struct Domain;
error = DomainError;
label = "domain";
validator = |s| {
let normalized = s.to_ascii_lowercase();
(normalized.len() <= 253
&& !normalized.is_empty()
&& normalized.split('.').all(|label| {
!label.is_empty()
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}))
.then_some(normalized)
.ok_or(())
};
}
impl Domain {
pub fn eq_name(&self, name: &str) -> bool {
self.as_str().eq_ignore_ascii_case(name)
}
pub fn strip_from<'h>(&self, handle: &'h str) -> Option<&'h str> {
let domain_len = self.as_str().len();
(handle.len() > domain_len + 1 && handle.as_bytes()[handle.len() - domain_len - 1] == b'.')
.then(|| &handle[..handle.len() - domain_len - 1])
.filter(|_| handle[handle.len() - domain_len..].eq_ignore_ascii_case(self.as_str()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AtIdentifier {
Did(Did),
@@ -1147,13 +1069,6 @@ impl ReachPolicy {
pub const DEBUG_LOOPBACK: ReachPolicy = ReachPolicy::AllowLoopback;
#[cfg(not(debug_assertions))]
pub const DEBUG_LOOPBACK: ReachPolicy = ReachPolicy::GlobalOnly;
pub fn from_private_fetch(allow_private: bool) -> ReachPolicy {
match allow_private {
true => ReachPolicy::AllowPrivate,
false => ReachPolicy::DEBUG_LOOPBACK,
}
}
}
pub trait UrlKind {
@@ -1673,126 +1588,6 @@ mod validated_newtype_tests {
);
}
#[test]
fn a_bare_did_ref_names_no_service() {
let aud = DidRef::new("did:plc:abc").unwrap();
assert_eq!(
aud.as_str(),
"did:plc:abc",
"an absent fragment is not the same as an empty one, so nothing may be appended"
);
}
#[test]
fn a_did_ref_keeps_the_service_id_it_was_given() {
let aud = DidRef::new("did:web:api.colibri.social#colibri_appview").unwrap();
assert_eq!(
aud.as_str(),
"did:web:api.colibri.social#colibri_appview",
"the fragment is what tells the receiver which of its services was audienced, \
so it must survive entirely"
);
}
#[test]
fn a_did_ref_normalizes_its_did_half_the_way_a_did_does() {
assert_eq!(
DidRef::new("at://did:plc:abc#colibri_appview")
.unwrap()
.as_str(),
"did:plc:abc#colibri_appview"
);
assert_eq!(
DidRef::new("did:plc:def").unwrap().as_str(),
Did::new("did:plc:def").unwrap().as_str(),
"a fragmentless DidRef must be byte-identical to the Did it replaces"
);
}
#[test]
fn a_did_ref_rejects_anything_that_cannot_name_one_service() {
for bad in [
"did:web:oyster.cafe#",
"did:web:oyster.cafe#a#b",
"did:web:oyster.cafe# whelk",
"did:web:oyster.cafe#a/b",
"did:web:oyster.cafe#a?b",
"did:web:oyster.cafe#<script>",
"did:web:oyster.cafe#a%20b",
"did:web:oyster.cafe#a%5Fb",
"did:web:oyster.cafe#a\"b",
"did:web:oyster.cafe#a[b]",
"did:web:oyster.cafe#atproto_p\u{200b}ds",
"did:web:oyster.cafe#\u{feff}atproto_pds",
"not-a-did#colibri_appview",
"#colibri_appview",
] {
assert!(
DidRef::new(bad).is_err(),
"{bad} should not parse as a DID reference"
);
}
}
#[test]
fn a_did_ref_does_not_second_guess_the_service_ids_it_has_not_seen() {
for good in [
"did:web:oyster.cafe#atproto_pds",
"did:web:oyster.cafe#atproto_labeler",
"did:plc:abc#bsky_chat",
"did:web:oyster.cafe#whelk.v2",
"did:web:oyster.cafe#atproto~pds",
] {
assert!(
DidRef::new(good).is_ok(),
"{good} names a service the receiver resolves in its own DID document, \
so rejecting it here would recreate the bug this type exists to fix"
);
}
}
#[test]
fn an_over_long_service_id_is_rejected_before_the_did_ref_bound_bites() {
let did = "did:web:oyster.cafe";
let longest_accepted = format!("{did}#{}", "a".repeat(SERVICE_ID_MAX_LEN));
assert!(DidRef::new(&longest_accepted).is_ok());
let one_too_long = format!("{did}#{}", "a".repeat(SERVICE_ID_MAX_LEN + 1));
assert!(
DidRef::new(&one_too_long).is_err(),
"no service names itself in more than {SERVICE_ID_MAX_LEN} bytes, and the 2048-byte \
aud bound is far too loose to catch a fragment used as a payload"
);
}
#[test]
fn an_over_long_did_ref_is_rejected() {
let did = format!("did:plc:{}", "a".repeat(DID_REF_MAX_LEN - "did:plc:".len()));
assert_eq!(did.len(), DID_REF_MAX_LEN);
assert!(
Did::new(&did).is_ok(),
"the DID half has to stand on its own, or the bound below proves nothing"
);
assert!(
DidRef::new(format!("{did}#x")).is_err(),
"the lexicon bounds aud at {DID_REF_MAX_LEN} bytes, which a whole DID plus the shortest service id already exceeds"
);
}
#[test]
fn a_did_ref_built_from_a_did_names_no_service() {
let did = Did::new("did:plc:def").unwrap();
assert_eq!(
DidRef::from(&did).as_str(),
did.as_str(),
"a DID that named no service must not gain one on the way in"
);
assert_eq!(
DidRef::from(did.clone()).as_str(),
did.as_str(),
"the owned conversion must land on the same bytes as the borrowed one"
);
}
#[test]
fn the_earliest_tid_sorts_below_every_generated_tid() {
let earliest = Tid::earliest();
-2
View File
@@ -96,8 +96,6 @@ services:
depends_on:
db:
condition: service_healthy
ports:
- "2582:2582"
mailpit:
profiles: [dev]
+1 -1
View File
@@ -66,7 +66,7 @@ See [example.toml](https://tangled.org/tranquil.farm/tranquil-pds/blob/main/exam
# by default, tranquil runs on port 3000.
# You can change this with the tranquil-pds.settings.server.port option in the service config.
extraConfig = ''
reverse_proxy [::1]:3000
reverse_proxy localhost:3000
'';
};
};
+1 -1
View File
@@ -19,4 +19,4 @@ Fixing this isn't too hard. You want to figure out what `seq` the relays think y
>
> #protip double check that the `seq` is actually misaligned with your PDS by getting the latest `seq` from your PDS using PDSls's firehose feature and a cursor value of `0` when connecting. If the relays don't have a `seq` that's bigger than your PDSs `seq` then this isn't your issue!
Now you need to update the PDS to use a `seq` that's *at least* one above the highest `seq` any of the relays have. Currently doing this on Tranquil depends on your used storage backend. For the default Postgres repo store you want to shutdown Tranquil itself, open the DB in `psql` and run `SELECT setval('firehose_seq', <new updated seq integer value>);` and the start Tranquil back up. Take a few actions to make sure new events get sent out. Everything should work once all the downstream consumers have had a chance to resync. You can double check with debug.hose.cam that all the relays show your PDS as `active`. Some might need a lil push with a request crawl, luckily debug.hose.cam also makes that easy :p (ignore that it says it failed to issue a request crawl, CORS is fickle).
Now you need to update the PDS to use a `seq` that's *at least* one above the highest `seq` any of the relays have. Currently doing this on Tranquil depends on your used storage backend. For the default Postgres repo store you want to shutdown Tranquil itself, open the DB in `psql` and run `SELECT setval('firehose_seq, <new updated seq value>');` and the start Tranquil back up. Take a few actions to make sure new events get sent out. Everything should work once all the downstream consumers have had a chance to resync. You can double check with debug.hose.cam that all the relays show your PDS as `active`. Some might need a lil push with a request crawl, luckily debug.hose.cam also makes that easy :p (ignore that it says it failed to issue a request crawl, CORS is fickle).
+2 -27
View File
@@ -10,8 +10,8 @@
#
# Can also be specified via environment variable `SERVER_HOST`.
#
# Default value: "[::1]"
#host = "[::1]"
# Default value: "127.0.0.1"
#host = "127.0.0.1"
# Port to bind the HTTP server to.
#
@@ -34,17 +34,6 @@
# Default value: false
#enable_pds_hosted_did_web = false
# The caddy on-demand TLS requires we serve
# the endpoint `/.well-known/caddy/ask`.
# It will be used so that caddy can create TLS
# certs for us on the fly
# and we don't have to do annoying wildcard certs.
#
# Can also be specified via environment variable `ENABLE_CADDY_ON_DEMAND_TLS`.
#
# Default value: true
#enable_caddy_on_demand_tls = true
# iykyk!
#
# Can also be specified via environment variable `RFC_MOO_COMPLIANCE`.
@@ -80,13 +69,6 @@
# Default value: false
#disable_rate_limiting = false
# Allow outbound fetches to private network addresses. Useful for local development using docker compose.
#
# Can also be specified via environment variable `ALLOW_PRIVATE_FETCH`.
#
# Default value: false
#allow_private_fetch = false
# Skip the verified-comms-channel gate for login and record writes.
# Please keep this off unless you're an invite-only PDS!
#
@@ -519,13 +501,6 @@
# Default value: 60
#total_timeout_secs = 60
# Apply Atmos/Comail.at categories for headers to be categorized appropriately.
#
# Can also be specified via environment variable `MAIL_APPLY_ATMOS_CATEGORIES`.
#
# Default value: false
#apply_atmos_categories = false
[email.direct_mx]
# Per-command SMTP timeout in seconds.
#
-1
View File
@@ -24,7 +24,6 @@
devShells = forAllSystems (pkgs: {
default = pkgs.callPackage ./shell.nix { };
full = pkgs.callPackage ./shells/full.nix { };
});
nixosModules = {
+1 -7
View File
@@ -634,13 +634,7 @@
"title": "Unexpected State",
"description": "The consent page is in an unexpected state. Please check the browser console for errors.",
"reload": "Reload Page"
},
"supersedeWarningTitle": "This app asked for broad access",
"supersedeWarningBody": "This application has requested full read and write access to your account.",
"supersededNote": "Already covered by transition:generic",
"deselectedWarningTitle": "Some permissions are disabled",
"deselectedWarningBody": "You have disabled some of the permissions requested by this app. This may cause some parts of the app to be broken or unavailable.",
"supersedeWarningBodyMixed": "This application has requested full read and write access to your account, alongside more specific permissions. The specific permissions are meaningless if you grant the application complete and total control by leaving transition:generic selected."
}
},
"accounts": {
"title": "Choose account",
+5 -57
View File
@@ -10,7 +10,6 @@
display_name: string
granted: boolean | null
restricted?: boolean
superseded?: boolean
effective_scope?: string
}
@@ -44,7 +43,6 @@
expanded: ScopeInfo[]
granted: boolean | null
restricted?: boolean
superseded?: boolean
}
type SetFailureReason =
@@ -83,7 +81,6 @@
logo_uri: string | null
scopes: ScopeInfo[]
permission_sets: PermissionSetInfo[]
transition_supersedes?: boolean
failed_sets: FailedSetInfo[]
show_consent: boolean
did: string
@@ -267,32 +264,10 @@
}
}
const TRANSITION_GENERIC = 'transition:generic'
let transitionGenericSelected = $derived(scopeSelections[TRANSITION_GENERIC] === true)
let anyDeselected = $derived(
Object.values(scopeSelections).some((selected) => selected === false)
)
function isSupersededNow(item: { superseded?: boolean }): boolean {
return Boolean(consentData?.transition_supersedes && item.superseded && transitionGenericSelected)
}
function handleScopeToggle(scope: string) {
const scopeInfo = consentData?.scopes.find(s => s.scope === scope)
if (scopeInfo?.required) return
if (scopeInfo && isSupersededNow(scopeInfo)) return
const next = !scopeSelections[scope]
scopeSelections[scope] = next
if (scope === TRANSITION_GENERIC && next) {
for (const s of consentData?.scopes ?? []) {
if (s.superseded && !s.restricted) scopeSelections[s.scope] = true
}
for (const set of consentData?.permission_sets ?? []) {
if (set.superseded && !set.restricted) scopeSelections[set.include_scope] = true
}
}
scopeSelections[scope] = !scopeSelections[scope]
}
const CATEGORY_ORDER = [
@@ -485,30 +460,6 @@
<span class="consent-account-did">{consentData.did}</span>
{/if}
</div>
{#if transitionGenericSelected}
<div class="permissions-notice" role="status">
<div class="notice-header">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>{$_('oauth.consent.supersedeWarningTitle')}</span>
</div>
<p class="notice-text">
{consentData.transition_supersedes
? $_('oauth.consent.supersedeWarningBodyMixed')
: $_('oauth.consent.supersedeWarningBody')}
</p>
</div>
{/if}
{#if anyDeselected}
<div class="permissions-notice" role="status">
<div class="notice-header">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>{$_('oauth.consent.deselectedWarningTitle')}</span>
</div>
<p class="notice-text">{$_('oauth.consent.deselectedWarningBody')}</p>
</div>
{/if}
</div>
<div class="permissions-panel">
@@ -531,8 +482,8 @@
<label class="scope-item" class:required={scope.required}>
<input
type="checkbox"
checked={isSupersededNow(scope) ? true : scopeSelections[scope.scope]}
disabled={scope.required || submitting || isSupersededNow(scope)}
checked={scopeSelections[scope.scope]}
disabled={scope.required || submitting}
onchange={() => handleScopeToggle(scope.scope)}
/>
<div class="scope-info">
@@ -541,9 +492,6 @@
{#if scope.required}
<span class="required-badge">{$_('oauth.consent.required')}</span>
{/if}
{#if isSupersededNow(scope)}
<span class="superseded-note">{$_('oauth.consent.supersededNote')}</span>
{/if}
</div>
</label>
{/each}
@@ -561,8 +509,8 @@
<label class="scope-item">
<input
type="checkbox"
checked={isSupersededNow(set) ? true : scopeSelections[set.include_scope]}
disabled={submitting || isSupersededNow(set)}
checked={scopeSelections[set.include_scope]}
disabled={submitting}
onchange={() => handleScopeToggle(set.include_scope)}
/>
<div class="scope-info">
+1 -6
View File
@@ -1085,8 +1085,7 @@ button.forget-btn:hover {
color: var(--text-muted);
}
.restricted-note,
.superseded-note {
.restricted-note {
display: block;
font-size: 0.75em;
color: var(--text-muted);
@@ -1590,7 +1589,3 @@ button.forget-btn:hover {
margin-left: auto;
}
}
.scope-item:has(input:disabled:checked) .scope-name {
color: var(--text-muted);
}
@@ -1,196 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import OAuthConsent from "../routes/OAuthConsent.svelte";
import {
clearMocks,
jsonResponse,
mockEndpoint,
setupFetchMock,
setupIndexedDBMock,
} from "./mocks.ts";
const consentPayload = {
request_uri: "urn:mock:request",
client_id: "https://example.com",
client_name: "Mixed Scope App",
client_uri: null,
logo_uri: null,
transition_supersedes: true,
scopes: [
{
scope: "atproto",
category: "Core Access",
required: true,
description: "Baseline",
display_name: "AT Protocol Access",
granted: null,
superseded: false,
},
{
scope: "transition:generic",
category: "Other",
required: false,
description: "Broad access",
display_name: "Generic Access",
granted: null,
superseded: false,
},
{
scope: "repo:app.bsky.feed.post?action=create",
category: "Other",
required: false,
description: "Create posts",
display_name: "repo:app.bsky.feed.post",
granted: null,
superseded: true,
},
{
scope: "account:email?action=manage",
category: "Other",
required: false,
description: "Manage email",
display_name: "account:email",
granted: null,
superseded: false,
},
{
scope: "transition:chat.bsky",
category: "Other",
required: false,
description: "Chat access",
display_name: "Chat Access",
granted: null,
superseded: false,
},
],
permission_sets: [],
failed_sets: [],
show_consent: true,
did: "did:plc:example",
};
function boxFor(name: string): HTMLInputElement {
const label = screen.getByText(name).closest("label");
if (!label) throw new Error(`no label containing "${name}"`);
const input = label.querySelector("input[type=checkbox]");
if (!input) throw new Error(`no checkbox in label for "${name}"`);
return input as HTMLInputElement;
}
describe("OAuthConsent transition:generic supersede behaviour", () => {
beforeEach(() => {
clearMocks();
setupFetchMock();
setupIndexedDBMock();
Object.defineProperty(window.location, "search", {
value: "?request_uri=urn:mock:request",
writable: true,
configurable: true,
});
mockEndpoint("/oauth/authorize/consent", () =>
jsonResponse(consentPayload),
);
});
it("warns that the itemised scopes are redundant", async () => {
render(OAuthConsent);
await waitFor(() =>
expect(screen.getByText(/asked for broad access/i)).toBeTruthy(),
);
expect(
screen.getByText(/specific permissions are meaningless/i),
).toBeTruthy();
});
it("warns about transition:generic even when nothing is superseded", async () => {
mockEndpoint("/oauth/authorize/consent", () =>
jsonResponse({
...consentPayload,
transition_supersedes: false,
scopes: consentPayload.scopes
.filter((s) => !s.superseded)
.map((s) => ({ ...s, superseded: false })),
}),
);
render(OAuthConsent);
await waitFor(() =>
expect(screen.getByText(/asked for broad access/i)).toBeTruthy(),
);
expect(screen.getByText(/full read and write access/i)).toBeTruthy();
expect(
screen.queryByText(/specific permissions are meaningless/i),
).toBeNull();
});
it("drops the warning once transition:generic is declined", async () => {
render(OAuthConsent);
await waitFor(() => expect(boxFor("Generic Access")).toBeTruthy());
await fireEvent.click(boxFor("Generic Access"));
await waitFor(() =>
expect(screen.queryByText(/asked for broad access/i)).toBeNull(),
);
});
it("locks superseded scopes checked while transition:generic is selected", async () => {
render(OAuthConsent);
await waitFor(() => expect(boxFor("Generic Access")).toBeTruthy());
const superseded = boxFor("repo:app.bsky.feed.post");
expect(superseded.checked).toBe(true);
expect(superseded.disabled).toBe(true);
});
it("leaves scopes generic does not cover editable", async () => {
render(OAuthConsent);
await waitFor(() => expect(boxFor("Chat Access")).toBeTruthy());
expect(boxFor("Chat Access").disabled).toBe(false);
expect(boxFor("account:email").disabled).toBe(false);
});
it("warns once a requested permission is switched off", async () => {
render(OAuthConsent);
await waitFor(() => expect(boxFor("Chat Access")).toBeTruthy());
expect(screen.queryByText(/disabled some of the permissions/i)).toBeNull();
await fireEvent.click(boxFor("Chat Access"));
await waitFor(() =>
expect(
screen.getByText(/disabled some of the permissions/i),
).toBeTruthy(),
);
});
it("hands control back once transition:generic is unchecked", async () => {
render(OAuthConsent);
await waitFor(() => expect(boxFor("Generic Access")).toBeTruthy());
await fireEvent.click(boxFor("Generic Access"));
await waitFor(() =>
expect(boxFor("repo:app.bsky.feed.post").disabled).toBe(false),
);
});
it("re-locks them when transition:generic is selected again", async () => {
render(OAuthConsent);
await waitFor(() => expect(boxFor("Generic Access")).toBeTruthy());
const generic = boxFor("Generic Access");
await fireEvent.click(generic);
await waitFor(() =>
expect(boxFor("repo:app.bsky.feed.post").disabled).toBe(false),
);
await fireEvent.click(boxFor("repo:app.bsky.feed.post"));
await fireEvent.click(boxFor("Generic Access"));
await waitFor(() => {
const superseded = boxFor("repo:app.bsky.feed.post");
expect(superseded.disabled).toBe(true);
expect(superseded.checked).toBe(true);
});
});
});
+14 -27
View File
@@ -98,56 +98,43 @@ test-store-asan:
test-unit:
SQLX_OFFLINE=true cargo test --test dpop_unit --test validation_edge_cases --test scope_edge_cases
store_test := "SQLX_OFFLINE=true TRANQUIL_TEST_BACKEND=store TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 DISABLE_RATE_LIMITING=1 TRANQUIL_LEXICON_OFFLINE=1 SKIP_IMPORT_VERIFICATION=true cargo nextest run -E 'not package(tranquil-store) and not binary(store_parity)'"
test-auth:
{{store_test}} --test oauth --test oauth_lifecycle --test oauth_scopes --test oauth_security --test jwt_security --test session_management --test change_password --test password_reset
./scripts/run-tests.sh --test oauth --test oauth_lifecycle --test oauth_scopes --test oauth_security --test jwt_security --test session_management --test change_password --test password_reset
test-admin:
{{store_test}} --test admin_email --test admin_invite --test admin_moderation --test admin_search --test admin_stats
./scripts/run-tests.sh --test admin_email --test admin_invite --test admin_moderation --test admin_search --test admin_stats
test-sync:
{{store_test}} --test sync_repo --test sync_blob --test sync_conformance --test sync_deprecated --test firehose_validation
./scripts/run-tests.sh --test sync_repo --test sync_blob --test sync_conformance --test sync_deprecated --test firehose_validation
test-repo:
{{store_test}} --test repo_batch --test repo_blob --test record_validation --test lifecycle_record
./scripts/run-tests.sh --test repo_batch --test repo_blob --test record_validation --test lifecycle_record
test-identity:
{{store_test}} --test identity --test did_web --test plc_migration --test plc_operations --test plc_validation
./scripts/run-tests.sh --test identity --test did_web --test plc_migration --test plc_operations --test plc_validation
test-account:
{{store_test}} --test lifecycle_session --test delete_account --test invite --test email_update --test account_notifications
./scripts/run-tests.sh --test lifecycle_session --test delete_account --test invite --test email_update --test account_notifications
test-security:
{{store_test}} --test security_fixes --test banned_words --test rate_limit --test moderation
./scripts/run-tests.sh --test security_fixes --test banned_words --test rate_limit --test moderation
test-import:
{{store_test}} --test import_verification --test import_with_verification
./scripts/run-tests.sh --test import_verification --test import_with_verification
test-misc:
{{store_test}} --test actor --test commit_signing --test image_processing --test lifecycle_social --test notifications --test server --test signing_key --test verify_live_commit
./scripts/run-tests.sh --test actor --test commit_signing --test image_processing --test lifecycle_social --test notifications --test server --test signing_key --test verify_live_commit
test *args:
@just test-unit
{{store_test}} {{args}}
test-one name:
{{store_test}} --test {{name}}
test-full *args:
@just test-unit
@just services-up
eval "$(tranquil-dev-services env)" && SQLX_OFFLINE=true cargo nextest run --features tranquil-pds/s3 -E 'not package(tranquil-store)' {{args}}
test-pg *args:
@just test-unit
./scripts/run-tests.sh {{args}}
services-up:
tranquil-dev-services up
test-embedded *args:
@just test-unit
SQLX_OFFLINE=true TRANQUIL_TEST_BACKEND=store TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 DISABLE_RATE_LIMITING=1 TRANQUIL_LEXICON_OFFLINE=1 SKIP_IMPORT_VERIFICATION=true cargo nextest run -E 'not binary(store_parity)' {{args}}
services-down:
tranquil-dev-services down
test-one name:
./scripts/run-tests.sh --test {{name}}
infra-start:
./scripts/test-infra.sh start
+1 -1
View File
@@ -76,7 +76,7 @@ in
server = {
host = mkOption {
type = types.str;
default = "[::1]";
default = "127.0.0.1";
description = "Host for tranquil-pds to listen on";
};
+5
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INFRA_SCRIPT="$SCRIPT_DIR/test-infra.sh"
cleanup() {
echo ""
@@ -11,6 +12,10 @@ trap cleanup EXIT
"$INFRA_SCRIPT" start
source "${TMPDIR:-/tmp}/tranquil_pds_test_infra.env"
echo ""
echo "Running database migrations..."
sqlx database create 2>/dev/null || true
sqlx migrate run --source "$PROJECT_DIR/migrations"
echo ""
ulimit -n 65536
echo "Building test binaries..."
+4
View File
@@ -5,6 +5,8 @@
# repo tooling
just,
podman,
podman-compose,
# rust tooling
clippy,
@@ -36,6 +38,8 @@ mkShell {
packages = [
just
podman
podman-compose
clippy
rustfmt
-182
View File
@@ -1,182 +0,0 @@
DEV_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/tranquil-dev-services"
ENV_FILE="$DEV_DIR/services.env"
PG_DIR="$DEV_DIR/pg"
PG_LOG="$DEV_DIR/pg.log"
PG_PORT=54329
PG_DATABASE=tranquil
GARAGE_DIR="$DEV_DIR/garage"
GARAGE_CONF="$GARAGE_DIR/garage.toml"
GARAGE_LOG="$DEV_DIR/garage.log"
GARAGE_PID_FILE="$DEV_DIR/garage.pid"
GARAGE_S3_PORT=3990
GARAGE_RPC_PORT=3901
GARAGE_RPC_SECRET=6465767365637265746465767365637265746465767365637265746465767365
S3_BUCKET=tranquil-dev
LOOPBACK="::1"
if [ "$(id -u)" = 0 ]; then
echo "PostgreSQL won't run as root, use a lowerclass user" >&2
exit 1
fi
write_garage_conf() {
mkdir -p "$GARAGE_DIR/meta" "$GARAGE_DIR/data"
cat > "$GARAGE_CONF" << EOF
metadata_dir = "$GARAGE_DIR/meta"
data_dir = "$GARAGE_DIR/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "[$LOOPBACK]:$GARAGE_RPC_PORT"
rpc_public_addr = "[$LOOPBACK]:$GARAGE_RPC_PORT"
rpc_secret = "$GARAGE_RPC_SECRET"
[s3_api]
s3_region = "tranquil"
api_bind_addr = "[$LOOPBACK]:$GARAGE_S3_PORT"
root_domain = ".s3.tranquil.dev"
EOF
}
garage_cli() {
garage -c "$GARAGE_CONF" "$@"
}
port_is_open() {
echo 2>/dev/null > "/dev/tcp/$LOOPBACK/$1"
}
wait_for_port() {
local port=$1
for _ in $(seq 1 60); do
if port_is_open "$port"; then
return 0
fi
sleep 0.5
done
echo "Nothing spun up on [$LOOPBACK]:$port" >&2
return 1
}
start_postgres() {
if [ -f "$PG_DIR/postmaster.pid" ] && pg_ctl -D "$PG_DIR" status >/dev/null 2>&1; then
echo "Postgres is already running"
return
fi
if [ ! -f "$PG_DIR/PG_VERSION" ]; then
mkdir -p "$PG_DIR"
initdb -D "$PG_DIR" -U postgres --auth=trust >/dev/null
fi
if ! pg_ctl -D "$PG_DIR" -l "$PG_LOG" -w \
-o "-p $PG_PORT -k $PG_DIR -c listen_addresses=$LOOPBACK" start >/dev/null; then
echo "Postgres wouldn't start. Please inspect $PG_LOG" >&2
exit 1
fi
wait_for_port "$PG_PORT"
if ! psql -h "$LOOPBACK" -p "$PG_PORT" -U postgres -lqt 2>/dev/null | cut -d'|' -f1 | grep -qw "$PG_DATABASE"; then
createdb -h "$LOOPBACK" -p "$PG_PORT" -U postgres "$PG_DATABASE"
fi
echo "PostgreSQL is up on [$LOOPBACK]:$PG_PORT"
}
layout_version() {
garage_cli layout show 2>/dev/null |
awk '/Current cluster layout version:/{print $NF; found=1} END{if (!found) print 0}'
}
start_garage() {
if port_is_open "$GARAGE_S3_PORT"; then
echo "Garage object storage is already running"
else
write_garage_conf
garage -c "$GARAGE_CONF" server >> "$GARAGE_LOG" 2>&1 &
echo $! > "$GARAGE_PID_FILE"
wait_for_port "$GARAGE_S3_PORT"
fi
local node_id
node_id=$(garage_cli node id 2>/dev/null |
awk 'match($0, /^[0-9a-f]{64}/) {print substr($0, RSTART, RLENGTH); exit}')
if [ -z "$node_id" ]; then
echo "Couldn't read the garage node id, please inspect $GARAGE_LOG" >&2
exit 1
fi
if ! garage_cli layout show 2>/dev/null | grep -q "${node_id:0:16}"; then
garage_cli layout assign "$node_id" -z dev -c 1GB
garage_cli layout apply --version "$(($(layout_version) + 1))"
fi
if ! garage_cli bucket list | grep -qw "$S3_BUCKET"; then
garage_cli bucket create "$S3_BUCKET" >/dev/null
fi
if ! garage_cli key list | grep -qw "$S3_BUCKET"; then
garage_cli key create "$S3_BUCKET" >/dev/null
fi
garage_cli bucket allow --read --write --owner "$S3_BUCKET" --key "$S3_BUCKET" >/dev/null
echo "Garage is up on [$LOOPBACK]:$GARAGE_S3_PORT"
}
write_env() {
local access_key secret_key
access_key=$(garage_cli key info "$S3_BUCKET" | awk '/^Key ID:/{print $3}')
secret_key=$(garage_cli key info --show-secret "$S3_BUCKET" | awk '/^Secret key:/{print $3}')
if [ -z "$access_key" ] || [ -z "$secret_key" ]; then
echo "Garage didn't show credentials for key $S3_BUCKET, please see $GARAGE_LOG" >&2
exit 1
fi
cat > "$ENV_FILE" << EOF
export DATABASE_URL="postgres://postgres@[$LOOPBACK]:$PG_PORT/$PG_DATABASE"
export TRANQUIL_PDS_TEST_INFRA_READY="1"
export TRANQUIL_PDS_ALLOW_INSECURE_SECRETS="1"
export DISABLE_RATE_LIMITING="1"
export TRANQUIL_LEXICON_OFFLINE="1"
export SKIP_IMPORT_VERIFICATION="1"
export BLOB_STORAGE_BACKEND="s3"
export S3_ENDPOINT="http://[$LOOPBACK]:$GARAGE_S3_PORT"
export S3_BUCKET="$S3_BUCKET"
export AWS_ACCESS_KEY_ID="$access_key"
export AWS_SECRET_ACCESS_KEY="$secret_key"
export AWS_REGION="tranquil"
EOF
}
stop_services() {
if [ -f "$PG_DIR/postmaster.pid" ]; then
pg_ctl -D "$PG_DIR" -m fast -w stop >/dev/null 2>&1 || true
fi
if [ -f "$GARAGE_PID_FILE" ]; then
local pid
pid=$(cat "$GARAGE_PID_FILE")
if [ "$(readlink "/proc/$pid/exe" 2>/dev/null)" = "$(command -v garage)" ]; then
kill "$pid" 2>/dev/null || true
for _ in $(seq 1 60); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
kill -0 "$pid" 2>/dev/null && echo "Garage $pid is taking its sweet time to exit" >&2
fi
rm -f "$GARAGE_PID_FILE"
fi
if port_is_open "$GARAGE_S3_PORT"; then
echo "Smth is still listening on [$LOOPBACK]:$GARAGE_S3_PORT, not to do with us" >&2
fi
echo "Services have been stopped"
}
case "${1:-}" in
up)
mkdir -p "$DEV_DIR"
start_postgres
start_garage
write_env
echo "env at $ENV_FILE"
;;
down)
stop_services
;;
env)
cat "$ENV_FILE"
;;
*)
echo "usage: tranquil-dev-services <up|down|env>" >&2
exit 1
;;
esac
-33
View File
@@ -1,33 +0,0 @@
{
mkShell,
callPackage,
writeShellApplication,
postgresql,
garage_2,
coreutils,
gnugrep,
gawk,
}:
let
devServices = writeShellApplication {
name = "tranquil-dev-services";
runtimeInputs = [
postgresql
garage_2
coreutils
gnugrep
gawk
];
text = builtins.readFile ./full-services.sh;
};
in
mkShell {
inputsFrom = [ (callPackage ../shell.nix { }) ];
packages = [
devServices
postgresql
garage_2
];
}
-6
View File
@@ -18,12 +18,6 @@ http:
service: frontend
priority: 1
tls: {}
handles:
rule: 'HostRegexp(`^.+\.pds\.test$`)'
entryPoints:
- websecure
service: backend
tls: {}
services:
backend: