Compare commits

...
Author SHA1 Message Date
Lewis e572cfabde caddy: on-demand TLS endpoint
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-31 10:37:19 +03:00
TrezyandTangled eba8167da8 chore: clean up supersedence shtuff
Signed-off-by: Trezy <tre@trezy.com>
2026-08-29 20:11:10 +00:00
TrezyandTangled 2e92310518 fix: allow transition:generic to be used with granular scopes
Signed-off-by: Trezy <tre@trezy.com>
2026-08-29 20:11:10 +00:00
nelindandTangled 0e82a38add fix(api): dont do rotation key validation in signPlcOperation as it blocks migrations 2026-08-29 05:42:03 +00:00
37 changed files with 1188 additions and 241 deletions
Generated
+2
View File
@@ -7780,6 +7780,8 @@ name = "tranquil-config"
version = "0.6.6"
dependencies = [
"confique",
"serde",
"tranquil-types",
]
[[package]]
@@ -66,11 +66,11 @@ pub async fn update_account_handle(
{
return Err(ApiError::InvalidHandle(None));
}
let available_domains = tranquil_config::get().server.available_user_domain_list();
let handle = if !input_handle.contains('.') {
format!("{}.{}", input_handle, &available_domains[0])
} else {
let primary = tranquil_pds::handle::ServiceDomains::for_user_handles().primary();
let handle = if input_handle.contains('.') {
input_handle.to_string()
} else {
format!("{}.{}", input_handle, primary)
};
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
let user_id = state
+13 -26
View File
@@ -132,12 +132,9 @@ 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 = 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)));
let is_subdomain = tranquil_pds::handle::ServiceDomains::served()
.split_handle(host_without_port)
.is_some();
if is_subdomain {
return serve_handle_did_doc(&state, host_without_port, hostname).await;
}
@@ -582,26 +579,16 @@ pub async fn update_handle(
"Inappropriate language in handle".into(),
)));
}
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))
}
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()),
),
};
if full_handle == current_handle {
let handle: Handle = match full_handle.parse() {
+1 -16
View File
@@ -9,10 +9,7 @@ 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, missing_required_rotation_key, sign_operation,
signing_key_to_did_key,
};
use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation};
use tranquil_pds::state::AppState;
#[derive(Debug, Deserialize)]
@@ -118,18 +115,6 @@ 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)
+8 -2
View File
@@ -467,9 +467,15 @@ pub fn api_routes() -> axum::Router<AppState> {
pub fn well_known_api_routes() -> axum::Router<AppState> {
use axum::routing::get;
axum::Router::new()
let routes = axum::Router::new()
.route("/did.json", get(identity::well_known_did))
.route("/atproto-did", get(identity::well_known_atproto_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
}
}
pub fn webhook_routes() -> axum::Router<AppState> {
+43
View File
@@ -0,0 +1,43 @@
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
}
}
}
+6 -1
View File
@@ -77,7 +77,12 @@ pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServ
let pds_hostname = &cfg.server.hostname;
Json(DescribeServerOutput {
available_user_domains: cfg.server.user_handle_domain_list(),
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()],
},
invite_code_required: cfg.server.invite_code_required,
did: format!("did:web:{}", pds_hostname),
links: DescribeServerLinks {
+2
View File
@@ -1,5 +1,6 @@
pub mod account_status;
pub mod app_password;
pub mod caddy;
pub mod email;
pub mod invite;
pub mod logo;
@@ -22,6 +23,7 @@ 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,
+2
View File
@@ -5,4 +5,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
tranquil-types = { workspace = true }
confique = { workspace = true }
+22 -21
View File
@@ -2,6 +2,7 @@ use confique::Config;
use std::fmt;
use std::path::PathBuf;
use std::sync::OnceLock;
use tranquil_types::Domain;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
@@ -30,7 +31,6 @@ 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,6 +224,12 @@ 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);
@@ -428,7 +434,7 @@ pub struct ServerConfig {
pub hostname: String,
/// Address to bind the HTTP server to.
#[config(env = "SERVER_HOST", default = "127.0.0.1")]
#[config(env = "SERVER_HOST", default = "[::1]")]
pub host: String,
/// Port to bind the HTTP server to.
@@ -438,13 +444,21 @@ 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<String>>,
pub user_handle_domains: Option<Vec<Domain>>,
/// 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,
@@ -573,20 +587,6 @@ 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)]
@@ -1484,12 +1484,13 @@ 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(value: &str) -> Result<Vec<String>, std::convert::Infallible> {
Ok(value
fn split_comma_list<T: std::str::FromStr>(value: &str) -> Result<Vec<T>, T::Err> {
value
.split(',')
.map(|item| item.trim().to_string())
.map(str::trim)
.filter(|item| !item.is_empty())
.collect())
.map(T::from_str)
.collect()
}
#[derive(Debug, Config)]
@@ -1,4 +1,5 @@
use super::*;
use tranquil_scopes::{ParsedScope, parse_scope};
use tranquil_types::Nsid;
#[derive(Debug, Serialize)]
@@ -10,6 +11,7 @@ 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>,
}
@@ -27,6 +29,7 @@ pub struct PermissionSetInfo {
pub expanded: Vec<ScopeInfo>,
pub granted: Option<bool>,
pub restricted: bool,
pub superseded: bool,
}
#[derive(Debug, Serialize)]
@@ -49,6 +52,7 @@ 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,
@@ -185,6 +189,9 @@ 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());
@@ -237,6 +244,8 @@ 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,
@@ -245,6 +254,7 @@ pub async fn consent_get(
display_name,
granted,
restricted,
superseded,
effective_scope,
}
};
@@ -267,6 +277,7 @@ 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(),
@@ -276,6 +287,7 @@ pub async fn consent_get(
include_scope,
expanded,
restricted,
superseded,
}
})
.collect();
@@ -332,6 +344,9 @@ 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(),
@@ -340,6 +355,7 @@ 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,32 +187,6 @@ 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,13 +789,16 @@ pub async fn check_handle_available(
}
};
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)
let available_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
if let Some(d) = &query.domain
&& !available_domains.contains(d.as_str())
{
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
}
let domain = query.domain.as_deref().unwrap_or(&available_domains[0]);
let domain = query
.domain
.as_deref()
.unwrap_or_else(|| available_domains.primary().as_str());
let full_handle = format!("{}.{}", validated, domain);
let handle: tranquil_pds::types::Handle = match full_handle.parse() {
Ok(h) => h,
@@ -882,34 +885,33 @@ pub async fn complete_registration(
let cfg = tranquil_config::get();
let hostname = &cfg.server.hostname;
let available_domains = cfg.server.available_user_domain_list();
let available_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let split = available_domains.split_handle(&input.handle);
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)),
}
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(),
};
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
+1 -2
View File
@@ -763,8 +763,7 @@ impl From<crate::api::validation::HandleValidationError> for ApiError {
HandleValidationError::BannedWord => {
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
}
HandleValidationError::UnusableHandleDomain
| HandleValidationError::NoHandleDomains => Self::InternalError(Some(e.to_string())),
HandleValidationError::UnusableHandleDomain => Self::InternalError(Some(e.to_string())),
_ => Self::InvalidHandle(Some(e.to_string())),
}
}
+9 -22
View File
@@ -111,7 +111,6 @@ pub enum HandleValidationError {
InvalidSyntax,
DisallowedTld,
UnusableHandleDomain,
NoHandleDomains,
}
impl std::fmt::Display for HandleValidationError {
@@ -143,9 +142,6 @@ 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")
}
}
}
}
@@ -215,21 +211,14 @@ pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationErr
}
pub fn resolve_handle_input(input: &str) -> Result<Handle, HandleValidationError> {
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());
let domains = crate::handle::ServiceDomains::for_user_handles();
let split = domains.split_handle(input);
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)?;
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)?;
let handle = Handle::new(format!("{}.{}", validated, domain))
.map_err(|_| HandleValidationError::InvalidSyntax)?;
match handle.has_disallowed_tld() {
@@ -246,11 +235,9 @@ pub fn domain_forms_valid_handles(domain: &str) -> bool {
}
pub fn warn_unusable_handle_domains() {
tranquil_config::get()
.server
.user_handle_domain_list()
crate::handle::ServiceDomains::for_user_handles()
.iter()
.filter(|domain| !domain_forms_valid_handles(domain))
.filter(|domain| !domain_forms_valid_handles(domain.as_str()))
.for_each(|domain| {
tracing::error!(
domain = %domain,
+133 -16
View File
@@ -3,8 +3,16 @@ 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}")]
@@ -85,28 +93,137 @@ pub async fn verify_handle_ownership(
}
}
pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
if !handle.contains('.') {
return true;
#[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())
}
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::*;
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()
}
#[test]
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"));
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
);
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ 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.map_or(false, |c| c.server.allow_private_fetch),
cfg.is_some_and(|c| c.server.allow_private_fetch),
);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
+113
View File
@@ -0,0 +1,113 @@
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);
}
+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: "Transition Access",
display_name: "Generic 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;
pub use permissions::{ScopePermissions, superseded_by_transition_generic};
+205 -32
View File
@@ -43,7 +43,26 @@ 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)
}
@@ -158,22 +177,17 @@ impl ScopePermissions {
}
pub fn assert_rpc(&self, aud: &str, lxm: &Nsid) -> Result<(), ScopeError> {
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
),
});
}
let is_chat = lxm.starts_with("chat.bsky.");
if is_chat && self.has_transition_chat {
return Ok(());
}
if self.has_transition_generic {
// `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(());
}
@@ -198,13 +212,24 @@ impl ScopePermissions {
});
if has_permission {
Ok(())
} else {
Err(ScopeError::InsufficientScope {
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 {
required: format!("rpc:{}?aud={}", lxm, aud),
message: format!("Insufficient scope to call {} on {}", lxm, aud),
})
}
},
})
}
pub fn assert_account(
@@ -212,10 +237,6 @@ 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(());
@@ -245,8 +266,7 @@ impl ScopePermissions {
}
pub fn allows_email_read(&self) -> bool {
self.has_transition_generic
|| self.has_transition_email
self.has_transition_email
|| self
.find_account_scopes()
.any(|a| a.attr == AccountAttr::Email || a.attr == AccountAttr::Wildcard)
@@ -269,10 +289,6 @@ 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
});
@@ -336,6 +352,7 @@ impl Default for ScopePermissions {
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_scope;
fn c(s: &str) -> Nsid {
s.parse().unwrap()
@@ -512,10 +529,10 @@ mod tests {
}
#[test]
fn test_transition_generic_grants_identity() {
fn test_transition_generic_does_not_grant_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]
@@ -597,4 +614,160 @@ mod tests {
&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")));
}
}
+5 -2
View File
@@ -77,9 +77,12 @@ async fn main() -> ExitCode {
}
config
.server
.user_handle_domain_list()
.user_handle_domains
.iter()
.filter(|d| !tranquil_pds::api::validation::domain_forms_valid_handles(d))
.flatten()
.filter(|d| {
!tranquil_pds::api::validation::domain_forms_valid_handles(d.as_str())
})
.for_each(|d| {
eprintln!(
"account creation under handle domain {d} will be rejected because its TLD is reserved"
+12 -4
View File
@@ -344,8 +344,12 @@ fn verify_backup_detects_checksum_mismatch() {
.unwrap();
let manifest = read_manifest(backup_dir.path()).unwrap();
let first_file = &manifest.files[0];
let file_path = backup_dir.path().join(&first_file.path);
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 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();
@@ -901,8 +905,12 @@ fn restore_fails_cleanly_on_corrupted_backup() {
.create_backup(backup_dir.path())
.unwrap();
let first_file = &manifest.files[0];
let file_path = backup_dir.path().join(&first_file.path);
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 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();
+32
View File
@@ -330,6 +330,38 @@ 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),
+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 localhost:3000
reverse_proxy [::1]:3000
'';
};
};
+13 -2
View File
@@ -10,8 +10,8 @@
#
# Can also be specified via environment variable `SERVER_HOST`.
#
# Default value: "127.0.0.1"
#host = "127.0.0.1"
# Default value: "[::1]"
#host = "[::1]"
# Port to bind the HTTP server to.
#
@@ -34,6 +34,17 @@
# 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`.
+1
View File
@@ -24,6 +24,7 @@
devShells = forAllSystems (pkgs: {
default = pkgs.callPackage ./shell.nix { };
full = pkgs.callPackage ./shells/full.nix { };
});
nixosModules = {
+7 -1
View File
@@ -634,7 +634,13 @@
"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",
+57 -5
View File
@@ -10,6 +10,7 @@
display_name: string
granted: boolean | null
restricted?: boolean
superseded?: boolean
effective_scope?: string
}
@@ -43,6 +44,7 @@
expanded: ScopeInfo[]
granted: boolean | null
restricted?: boolean
superseded?: boolean
}
type SetFailureReason =
@@ -81,6 +83,7 @@
logo_uri: string | null
scopes: ScopeInfo[]
permission_sets: PermissionSetInfo[]
transition_supersedes?: boolean
failed_sets: FailedSetInfo[]
show_consent: boolean
did: string
@@ -264,10 +267,32 @@
}
}
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
scopeSelections[scope] = !scopeSelections[scope]
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
}
}
}
const CATEGORY_ORDER = [
@@ -460,6 +485,30 @@
<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">
@@ -482,8 +531,8 @@
<label class="scope-item" class:required={scope.required}>
<input
type="checkbox"
checked={scopeSelections[scope.scope]}
disabled={scope.required || submitting}
checked={isSupersededNow(scope) ? true : scopeSelections[scope.scope]}
disabled={scope.required || submitting || isSupersededNow(scope)}
onchange={() => handleScopeToggle(scope.scope)}
/>
<div class="scope-info">
@@ -492,6 +541,9 @@
{#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}
@@ -509,8 +561,8 @@
<label class="scope-item">
<input
type="checkbox"
checked={scopeSelections[set.include_scope]}
disabled={submitting}
checked={isSupersededNow(set) ? true : scopeSelections[set.include_scope]}
disabled={submitting || isSupersededNow(set)}
onchange={() => handleScopeToggle(set.include_scope)}
/>
<div class="scope-info">
+6 -1
View File
@@ -1085,7 +1085,8 @@ button.forget-btn:hover {
color: var(--text-muted);
}
.restricted-note {
.restricted-note,
.superseded-note {
display: block;
font-size: 0.75em;
color: var(--text-muted);
@@ -1589,3 +1590,7 @@ button.forget-btn:hover {
margin-left: auto;
}
}
.scope-item:has(input:disabled:checked) .scope-name {
color: var(--text-muted);
}
@@ -0,0 +1,196 @@
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);
});
});
});
+28 -15
View File
@@ -98,43 +98,56 @@ 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:
./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
{{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
test-admin:
./scripts/run-tests.sh --test admin_email --test admin_invite --test admin_moderation --test admin_search --test admin_stats
{{store_test}} --test admin_email --test admin_invite --test admin_moderation --test admin_search --test admin_stats
test-sync:
./scripts/run-tests.sh --test sync_repo --test sync_blob --test sync_conformance --test sync_deprecated --test firehose_validation
{{store_test}} --test sync_repo --test sync_blob --test sync_conformance --test sync_deprecated --test firehose_validation
test-repo:
./scripts/run-tests.sh --test repo_batch --test repo_blob --test record_validation --test lifecycle_record
{{store_test}} --test repo_batch --test repo_blob --test record_validation --test lifecycle_record
test-identity:
./scripts/run-tests.sh --test identity --test did_web --test plc_migration --test plc_operations --test plc_validation
{{store_test}} --test identity --test did_web --test plc_migration --test plc_operations --test plc_validation
test-account:
./scripts/run-tests.sh --test lifecycle_session --test delete_account --test invite --test email_update --test account_notifications
{{store_test}} --test lifecycle_session --test delete_account --test invite --test email_update --test account_notifications
test-security:
./scripts/run-tests.sh --test security_fixes --test banned_words --test rate_limit --test moderation
{{store_test}} --test security_fixes --test banned_words --test rate_limit --test moderation
test-import:
./scripts/run-tests.sh --test import_verification --test import_with_verification
{{store_test}} --test import_verification --test import_with_verification
test-misc:
./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
{{store_test}} --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
./scripts/run-tests.sh {{args}}
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}}
{{store_test}} {{args}}
test-one name:
./scripts/run-tests.sh --test {{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
services-down:
tranquil-dev-services down
infra-start:
./scripts/test-infra.sh start
+1 -1
View File
@@ -76,7 +76,7 @@ in
server = {
host = mkOption {
type = types.str;
default = "127.0.0.1";
default = "[::1]";
description = "Host for tranquil-pds to listen on";
};
-5
View File
@@ -1,7 +1,6 @@
#!/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 ""
@@ -12,10 +11,6 @@ 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,8 +5,6 @@
# repo tooling
just,
podman,
podman-compose,
# rust tooling
clippy,
@@ -38,8 +36,6 @@ mkShell {
packages = [
just
podman
podman-compose
clippy
rustfmt
+182
View File
@@ -0,0 +1,182 @@
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
@@ -0,0 +1,33 @@
{
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
];
}