mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-31 13:17:05 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37234797b4 | ||
|
|
6297b1a451 | ||
|
|
107149f396 | ||
|
|
bd47cbdaa4 | ||
|
|
9840ac77cf | ||
|
|
420ce1e201 | ||
|
|
c723bc2164 |
Generated
-2
@@ -7780,8 +7780,6 @@ name = "tranquil-config"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
"tranquil-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
¶ms.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, ¶ms.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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,4 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
tranquil-types = { workspace = true }
|
||||
confique = { workspace = true }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -96,8 +96,6 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "2582:2582"
|
||||
|
||||
mailpit:
|
||||
profiles: [dev]
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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.
|
||||
#
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
|
||||
devShells = forAllSystems (pkgs: {
|
||||
default = pkgs.callPackage ./shell.nix { };
|
||||
full = pkgs.callPackage ./shells/full.nix { };
|
||||
});
|
||||
|
||||
nixosModules = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -18,12 +18,6 @@ http:
|
||||
service: frontend
|
||||
priority: 1
|
||||
tls: {}
|
||||
handles:
|
||||
rule: 'HostRegexp(`^.+\.pds\.test$`)'
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: backend
|
||||
tls: {}
|
||||
|
||||
services:
|
||||
backend:
|
||||
|
||||
Reference in New Issue
Block a user