refactor(api): update delegation, notification prefs, email, meta, and age assurance endpoints

This commit is contained in:
Lewis
2026-03-20 13:39:11 +00:00
committed by Tangled
parent d123168787
commit c8bb4c9076
6 changed files with 397 additions and 407 deletions
+3 -3
View File
@@ -69,7 +69,7 @@ pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissiv
if let Some(age) = personal_details_pref
.as_ref()
.and_then(|pref| pref.get("birthDate"))
.and_then(|v| v.as_str())
.and_then(Value::as_str)
.and_then(get_age_from_datestring)
{
let declared_age_pref = serde_json::json!({
@@ -122,7 +122,7 @@ pub async fn put_preferences(
if pref_str.len() > MAX_PREFERENCE_SIZE {
return PrefValidation::TooLarge(pref_str.len());
}
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
let pref_type = match pref.get("$type").and_then(Value::as_str) {
Some(t) => t,
None => return PrefValidation::MissingType,
};
@@ -179,7 +179,7 @@ pub async fn put_preferences(
.preferences
.into_iter()
.filter_map(|pref| {
let pref_type = pref.get("$type").and_then(|t| t.as_str())?;
let pref_type = pref.get("$type").and_then(Value::as_str)?;
if pref_type == DECLARED_AGE_PREF {
return None;
}
+43 -20
View File
@@ -1,37 +1,60 @@
use axum::{
Json,
extract::State,
http::{HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
http::{HeaderMap, Method},
};
use serde_json::json;
use serde::Serialize;
use tranquil_pds::auth::{
AccountRequirement, extract_auth_token_from_header, validate_token_with_dpop,
};
use tranquil_pds::state::AppState;
pub async fn get_state(State(state): State<AppState>, headers: HeaderMap) -> Response {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgeAssuranceState {
pub status: &'static str,
pub access: &'static str,
pub last_initiated_at: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgeAssuranceMetadata {
pub account_created_at: Option<String>,
}
#[derive(Serialize)]
pub struct GetAgeAssuranceOutput {
pub state: AgeAssuranceState,
pub metadata: AgeAssuranceMetadata,
}
#[derive(Serialize)]
pub struct AgeAssuranceStatusOutput {
pub status: &'static str,
}
pub async fn get_state(
State(state): State<AppState>,
headers: HeaderMap,
) -> Json<GetAgeAssuranceOutput> {
let created_at = get_account_created_at(&state, &headers).await;
let now = chrono::Utc::now().to_rfc3339();
(
StatusCode::OK,
Json(json!({
"state": {
"status": "assured",
"access": "full",
"lastInitiatedAt": now
},
"metadata": {
"accountCreatedAt": created_at
}
})),
)
.into_response()
Json(GetAgeAssuranceOutput {
state: AgeAssuranceState {
status: "assured",
access: "full",
last_initiated_at: now,
},
metadata: AgeAssuranceMetadata {
account_created_at: created_at,
},
})
}
pub async fn get_age_assurance_state() -> Response {
(StatusCode::OK, Json(json!({"status": "assured"}))).into_response()
pub async fn get_age_assurance_state() -> Json<AgeAssuranceStatusOutput> {
Json(AgeAssuranceStatusOutput { status: "assured" })
}
async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option<String> {
+75 -135
View File
@@ -2,13 +2,14 @@ use crate::identity::provision::{create_plc_did, init_genesis_repo};
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::{
AccountsOutput, AuditLogOutput, ControllersOutput, PresetsOutput, SuccessResponse,
};
use tranquil_pds::auth::{Active, Auth};
use tranquil_pds::delegation::{
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
@@ -21,20 +22,15 @@ use tranquil_pds::types::{Did, Handle};
pub async fn list_controllers(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
let controllers = match state
) -> Result<Json<ControllersOutput<Vec<tranquil_db_traits::ControllerInfo>>>, ApiError> {
let controllers = state
.delegation_repo
.get_delegations_for_account(&auth.did)
.await
{
Ok(c) => c,
Err(e) => {
.map_err(|e| {
tracing::error!("Failed to list controllers: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to list controllers".into())).into_response(),
);
}
};
ApiError::InternalError(Some("Failed to list controllers".into()))
})?;
let resolve_futures = controllers.into_iter().map(|mut c| {
let did_resolver = state.did_resolver.clone();
@@ -44,7 +40,7 @@ pub async fn list_controllers(
.resolve_did_document(c.did.as_str())
.await
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
.map(|h| h.into());
.map(Into::into);
}
c
}
@@ -52,7 +48,7 @@ pub async fn list_controllers(
let controllers = futures::future::join_all(resolve_futures).await;
Ok(Json(serde_json::json!({ "controllers": controllers })).into_response())
Ok(Json(ControllersOutput { controllers }))
}
#[derive(Debug, Deserialize)]
@@ -65,7 +61,7 @@ pub async fn add_controller(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<AddControllerInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SuccessResponse>, ApiError> {
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
.await
.ok_or(ApiError::ControllerNotFound)?;
@@ -74,9 +70,9 @@ pub async fn add_controller(
&& let Some(ref pds_url) = resolved.pds_url
{
if !pds_url.starts_with("https://") {
return Ok(
ApiError::InvalidDelegation("Controller PDS must use HTTPS".into()).into_response(),
);
return Err(ApiError::InvalidDelegation(
"Controller PDS must use HTTPS".into(),
));
}
match state
.cross_pds_oauth
@@ -84,10 +80,9 @@ pub async fn add_controller(
.await
{
Some(true) => {
return Ok(ApiError::InvalidDelegation(
return Err(ApiError::InvalidDelegation(
"Cannot add a delegated account from another PDS as a controller".into(),
)
.into_response());
));
}
Some(false) => {}
None => {
@@ -100,10 +95,7 @@ pub async fn add_controller(
}
}
let can_add = match verify_can_add_controllers(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
let can_add = verify_can_add_controllers(&state, &auth).await?;
if resolved.is_local
&& state
@@ -112,10 +104,9 @@ pub async fn add_controller(
.await
.unwrap_or(false)
{
return Ok(ApiError::InvalidDelegation(
return Err(ApiError::InvalidDelegation(
"Cannot add a controlled account as a controller".into(),
)
.into_response());
));
}
match state
@@ -136,7 +127,7 @@ pub async fn add_controller(
can_add.did(),
Some(&input.controller_did),
DelegationActionType::GrantCreated,
Some(serde_json::json!({
Some(json!({
"granted_scopes": input.granted_scopes.as_str(),
"is_local": resolved.is_local
})),
@@ -145,17 +136,13 @@ pub async fn add_controller(
)
.await;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response())
Ok(Json(SuccessResponse { success: true }))
}
Err(e) => {
tracing::error!("Failed to add controller: {:?}", e);
Ok(ApiError::InternalError(Some("Failed to add controller".into())).into_response())
Err(ApiError::InternalError(Some(
"Failed to add controller".into(),
)))
}
}
}
@@ -169,7 +156,7 @@ pub async fn remove_controller(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<RemoveControllerInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.delegation_repo
.revoke_delegation(&auth.did, &input.controller_did, &auth.did)
@@ -197,7 +184,7 @@ pub async fn remove_controller(
&auth.did,
Some(&input.controller_did),
DelegationActionType::GrantRevoked,
Some(serde_json::json!({
Some(json!({
"revoked_app_passwords": revoked_app_passwords,
"revoked_oauth_tokens": revoked_oauth_tokens
})),
@@ -206,18 +193,14 @@ pub async fn remove_controller(
)
.await;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response())
Ok(Json(SuccessResponse { success: true }))
}
Ok(false) => Ok(ApiError::DelegationNotFound.into_response()),
Ok(false) => Err(ApiError::DelegationNotFound),
Err(e) => {
tracing::error!("Failed to remove controller: {:?}", e);
Ok(ApiError::InternalError(Some("Failed to remove controller".into())).into_response())
Err(ApiError::InternalError(Some(
"Failed to remove controller".into(),
)))
}
}
}
@@ -232,7 +215,7 @@ pub async fn update_controller_scopes(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<UpdateControllerScopesInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.delegation_repo
.update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes)
@@ -246,7 +229,7 @@ pub async fn update_controller_scopes(
&auth.did,
Some(&input.controller_did),
DelegationActionType::ScopesModified,
Some(serde_json::json!({
Some(json!({
"new_scopes": input.granted_scopes.as_str()
})),
None,
@@ -254,21 +237,14 @@ pub async fn update_controller_scopes(
)
.await;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response())
Ok(Json(SuccessResponse { success: true }))
}
Ok(false) => Ok(ApiError::DelegationNotFound.into_response()),
Ok(false) => Err(ApiError::DelegationNotFound),
Err(e) => {
tracing::error!("Failed to update controller scopes: {:?}", e);
Ok(
ApiError::InternalError(Some("Failed to update controller scopes".into()))
.into_response(),
)
Err(ApiError::InternalError(Some(
"Failed to update controller scopes".into(),
)))
}
}
}
@@ -276,23 +252,17 @@ pub async fn update_controller_scopes(
pub async fn list_controlled_accounts(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
let accounts = match state
) -> Result<Json<AccountsOutput<Vec<tranquil_db_traits::DelegatedAccountInfo>>>, ApiError> {
let accounts = state
.delegation_repo
.get_accounts_controlled_by(&auth.did)
.await
{
Ok(a) => a,
Err(e) => {
.map_err(|e| {
tracing::error!("Failed to list controlled accounts: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to list controlled accounts".into()))
.into_response(),
);
}
};
ApiError::InternalError(Some("Failed to list controlled accounts".into()))
})?;
Ok(Json(serde_json::json!({ "accounts": accounts })).into_response())
Ok(Json(AccountsOutput { accounts }))
}
#[derive(Debug, Deserialize)]
@@ -311,23 +281,18 @@ pub async fn get_audit_log(
State(state): State<AppState>,
auth: Auth<Active>,
Query(params): Query<AuditLogParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<AuditLogOutput<Vec<tranquil_db_traits::AuditLogEntry>>>, ApiError> {
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let entries = match state
let entries = state
.delegation_repo
.get_audit_log_for_account(&auth.did, limit, offset)
.await
{
Ok(e) => e,
Err(e) => {
.map_err(|e| {
tracing::error!("Failed to get audit log: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to get audit log".into())).into_response(),
);
}
};
ApiError::InternalError(Some("Failed to get audit log".into()))
})?;
let total = state
.delegation_repo
@@ -335,11 +300,14 @@ pub async fn get_audit_log(
.await
.unwrap_or_default();
Ok(Json(serde_json::json!({ "entries": entries, "total": total })).into_response())
Ok(Json(AuditLogOutput { entries, total }))
}
pub async fn get_scope_presets() -> Response {
Json(serde_json::json!({ "presets": SCOPE_PRESETS })).into_response()
pub async fn get_scope_presets()
-> Json<PresetsOutput<&'static [tranquil_pds::delegation::ScopePreset]>> {
Json(PresetsOutput {
presets: SCOPE_PRESETS,
})
}
#[derive(Debug, Deserialize)]
@@ -353,7 +321,7 @@ pub struct CreateDelegatedAccountInput {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDelegatedAccountResponse {
pub struct CreateDelegatedAccountOutput {
pub did: Did,
pub handle: Handle,
}
@@ -363,18 +331,11 @@ pub async fn create_delegated_account(
_rate_limit: RateLimited<AccountCreationLimit>,
auth: Auth<Active>,
Json(input): Json<CreateDelegatedAccountInput>,
) -> Result<Response, ApiError> {
let can_control = match verify_can_control_accounts(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
) -> Result<Json<CreateDelegatedAccountOutput>, ApiError> {
let can_control = verify_can_control_accounts(&state, &auth).await?;
let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) {
Ok(h) => h,
Err(e) => {
return Ok(ApiError::InvalidRequest(e.to_string()).into_response());
}
};
let handle = tranquil_pds::api::validation::resolve_handle_input(&input.handle)
.map_err(|e| ApiError::InvalidRequest(e.to_string()))?;
let email = input
.email
@@ -384,18 +345,18 @@ pub async fn create_delegated_account(
if let Some(ref email) = email
&& !tranquil_pds::api::validation::is_valid_email(email)
{
return Ok(ApiError::InvalidEmail.into_response());
return Err(ApiError::InvalidEmail);
}
let validated_invite_code = if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
} else {
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Ok(ApiError::InviteCodeRequired.into_response());
return Err(ApiError::InviteCodeRequired);
}
None
};
@@ -409,6 +370,7 @@ pub async fn create_delegated_account(
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?;
let repo_for_seq = repo.clone();
let create_input = tranquil_db_traits::CreateDelegatedAccountInput {
handle: handle.clone(),
@@ -431,14 +393,14 @@ pub async fn create_delegated_account(
{
Ok(id) => id,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return Ok(ApiError::HandleNotAvailable(None).into_response());
return Err(ApiError::HandleNotAvailable(None));
}
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
return Ok(ApiError::EmailTaken.into_response());
return Err(ApiError::EmailTaken);
}
Err(e) => {
error!("Error creating delegated account: {:?}", e);
return Ok(ApiError::InternalError(None).into_response());
return Err(ApiError::InternalError(None));
}
};
@@ -451,36 +413,14 @@ pub async fn create_delegated_account(
warn!("Failed to record invite code use for {}: {:?}", did, e);
}
if let Err(e) =
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
crate::identity::provision::sequence_new_account(
&state,
&did,
tranquil_db_traits::AccountStatus::Active,
&handle,
&repo_for_seq,
handle.as_str(),
)
.await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
warn!("Failed to create default profile for {}: {}", did, e);
}
.await;
let _ = state
.delegation_repo
@@ -500,7 +440,7 @@ pub async fn create_delegated_account(
info!(did = %did, handle = %handle, controller = %&auth.did, "Delegated account created");
Ok(Json(CreateDelegatedAccountResponse { did, handle }).into_response())
Ok(Json(CreateDelegatedAccountOutput { did, handle }))
}
#[derive(Debug, Deserialize)]
@@ -511,7 +451,7 @@ pub struct ResolveControllerParams {
pub async fn resolve_controller(
State(state): State<AppState>,
Query(params): Query<ResolveControllerParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<tranquil_pds::delegation::ResolvedIdentity>, ApiError> {
let identifier = params.identifier.trim().trim_start_matches('@');
let did: Did = if identifier.starts_with("did:") {
@@ -538,5 +478,5 @@ pub async fn resolve_controller(
.await
.ok_or(ApiError::ControllerNotFound)?;
Ok(Json(resolved).into_response())
Ok(Json(resolved))
}
+145 -124
View File
@@ -1,20 +1,16 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::info;
use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Active, Auth};
use tranquil_pds::state::AppState;
use tranquil_types::Did;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPrefsResponse {
pub struct NotificationPrefsOutput {
pub preferred_channel: CommsChannel,
pub email: String,
pub discord_username: Option<String>,
@@ -28,14 +24,14 @@ pub struct NotificationPrefsResponse {
pub async fn get_notification_prefs(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<NotificationPrefsOutput>, ApiError> {
let prefs = state
.user_repo
.get_notification_prefs(&auth.did)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
.log_db_err("get notification prefs")?
.ok_or(ApiError::AccountNotFound)?;
Ok(Json(NotificationPrefsResponse {
Ok(Json(NotificationPrefsOutput {
preferred_channel: prefs.preferred_channel,
email: prefs.email,
discord_username: prefs.discord_username,
@@ -44,8 +40,7 @@ pub async fn get_notification_prefs(
telegram_verified: prefs.telegram_verified,
signal_username: prefs.signal_username,
signal_verified: prefs.signal_verified,
})
.into_response())
}))
}
#[derive(Serialize)]
@@ -61,26 +56,26 @@ pub struct NotificationHistoryEntry {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetNotificationHistoryResponse {
pub struct GetNotificationHistoryOutput {
pub notifications: Vec<NotificationHistoryEntry>,
}
pub async fn get_notification_history(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<GetNotificationHistoryOutput>, ApiError> {
let user_id = state
.user_repo
.get_id_by_did(&auth.did)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
.log_db_err("get user id by did")?
.ok_or(ApiError::AccountNotFound)?;
let rows = state
.infra_repo
.get_notification_history(user_id, 50)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
.log_db_err("get notification history")?;
let sensitive_types = [
CommsType::EmailVerification,
@@ -112,7 +107,7 @@ pub async fn get_notification_history(
})
.collect();
Ok(Json(GetNotificationHistoryResponse { notifications }).into_response())
Ok(Json(GetNotificationHistoryOutput { notifications }))
}
#[derive(Deserialize)]
@@ -127,7 +122,7 @@ pub struct UpdateNotificationPrefsInput {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateNotificationPrefsResponse {
pub struct UpdateNotificationPrefsOutput {
pub success: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub verification_required: Vec<CommsChannel>,
@@ -159,12 +154,7 @@ pub async fn request_channel_verification(
hostname,
)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!(
"Failed to enqueue email notification: {}",
e
)))
})?;
.log_db_err("enqueue email verification")?;
}
_ => {
let hostname = &tranquil_config::get().server.hostname;
@@ -216,25 +206,117 @@ pub async fn request_channel_verification(
Some(json!({"code": formatted_token})),
)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to enqueue notification: {}", e)))
})?;
.log_db_err("enqueue channel verification")?;
}
}
Ok(token)
}
async fn process_messaging_channel_update(
state: &AppState,
user_id: uuid::Uuid,
did: &Did,
channel: CommsChannel,
raw_value: &str,
effective_channel: CommsChannel,
verification_required: &mut Vec<CommsChannel>,
) -> Result<(), ApiError> {
let clean = match channel {
CommsChannel::Discord => raw_value.trim().to_lowercase(),
CommsChannel::Telegram => raw_value.trim_start_matches('@').to_string(),
CommsChannel::Signal => raw_value.trim().trim_start_matches('@').to_lowercase(),
CommsChannel::Email => raw_value.trim().to_lowercase(),
};
if clean.is_empty() {
if effective_channel == channel {
return Err(ApiError::InvalidRequest(format!(
"Cannot remove {:?} while it is the preferred notification channel",
channel
)));
}
match channel {
CommsChannel::Discord => state
.user_repo
.clear_discord(user_id)
.await
.log_db_err("clear discord")?,
CommsChannel::Telegram => state
.user_repo
.clear_telegram(user_id)
.await
.log_db_err("clear telegram")?,
CommsChannel::Signal => state
.user_repo
.clear_signal(user_id)
.await
.log_db_err("clear signal")?,
CommsChannel::Email => {}
};
info!(did = %did, channel = ?channel, "Cleared channel");
return Ok(());
}
let valid = match channel {
CommsChannel::Discord => tranquil_pds::api::validation::is_valid_discord_username(&clean),
CommsChannel::Telegram => tranquil_pds::api::validation::is_valid_telegram_username(&clean),
CommsChannel::Signal => tranquil_pds::comms::is_valid_signal_username(&clean),
CommsChannel::Email => tranquil_pds::api::validation::is_valid_email(&clean),
};
if !valid {
return Err(match channel {
CommsChannel::Discord => ApiError::InvalidRequest(
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
),
CommsChannel::Telegram => ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
),
CommsChannel::Signal => ApiError::InvalidRequest(
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)".into(),
),
CommsChannel::Email => ApiError::InvalidEmail,
});
}
match channel {
CommsChannel::Discord => state
.user_repo
.set_unverified_discord(user_id, &clean)
.await
.log_db_err("set unverified discord")?,
CommsChannel::Telegram => state
.user_repo
.set_unverified_telegram(user_id, &clean)
.await
.log_db_err("set unverified telegram")?,
CommsChannel::Signal => state
.user_repo
.set_unverified_signal(user_id, &clean)
.await
.log_db_err("set unverified signal")?,
CommsChannel::Email => {}
};
if matches!(channel, CommsChannel::Signal) {
request_channel_verification(state, user_id, did, channel, &clean, None).await?;
}
verification_required.push(channel);
info!(did = %did, channel = ?channel, value = %clean, "Stored unverified channel username");
Ok(())
}
pub async fn update_notification_prefs(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<UpdateNotificationPrefsInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<UpdateNotificationPrefsOutput>, ApiError> {
let user_row = state
.user_repo
.get_id_handle_email_by_did(&auth.did)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
.log_db_err("get user by did")?
.ok_or(ApiError::AccountNotFound)?;
let user_id = user_row.id;
@@ -245,7 +327,7 @@ pub async fn update_notification_prefs(
.user_repo
.get_notification_prefs(&auth.did)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
.log_db_err("get notification prefs for update")?
.ok_or(ApiError::AccountNotFound)?;
let effective_channel = input
@@ -268,7 +350,7 @@ pub async fn update_notification_prefs(
.user_repo
.update_preferred_comms_channel(&auth.did, effective_channel)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
.log_db_err("update preferred channel")?;
info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel");
}
@@ -298,107 +380,46 @@ pub async fn update_notification_prefs(
}
if let Some(ref discord_username) = input.discord_username {
let discord_clean = discord_username.trim().to_lowercase();
if discord_clean.is_empty() {
if effective_channel == CommsChannel::Discord {
return Err(ApiError::InvalidRequest(
"Cannot remove Discord while it is the preferred notification channel".into(),
));
}
state
.user_repo
.clear_discord(user_id)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
info!(did = %auth.did, "Cleared Discord");
} else if !tranquil_pds::api::validation::is_valid_discord_username(&discord_clean) {
return Err(ApiError::InvalidRequest(
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)"
.into(),
));
} else {
state
.user_repo
.set_unverified_discord(user_id, &discord_clean)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
verification_required.push(CommsChannel::Discord);
info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username");
}
process_messaging_channel_update(
&state,
user_id,
&auth.did,
CommsChannel::Discord,
discord_username,
effective_channel,
&mut verification_required,
)
.await?;
}
if let Some(ref telegram) = input.telegram_username {
let telegram_clean = telegram.trim_start_matches('@');
if telegram_clean.is_empty() {
if effective_channel == CommsChannel::Telegram {
return Err(ApiError::InvalidRequest(
"Cannot remove Telegram while it is the preferred notification channel".into(),
));
}
state
.user_repo
.clear_telegram(user_id)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
info!(did = %auth.did, "Cleared Telegram username");
} else if !tranquil_pds::api::validation::is_valid_telegram_username(telegram_clean) {
return Err(ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore"
.into(),
));
} else {
state
.user_repo
.set_unverified_telegram(user_id, telegram_clean)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
verification_required.push(CommsChannel::Telegram);
info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified Telegram username");
}
process_messaging_channel_update(
&state,
user_id,
&auth.did,
CommsChannel::Telegram,
telegram,
effective_channel,
&mut verification_required,
)
.await?;
}
if let Some(ref signal) = input.signal_username {
let signal_clean = signal.trim().trim_start_matches('@').to_lowercase();
if signal_clean.is_empty() {
if effective_channel == CommsChannel::Signal {
return Err(ApiError::InvalidRequest(
"Cannot remove Signal while it is the preferred notification channel".into(),
));
}
state
.user_repo
.clear_signal(user_id)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
info!(did = %auth.did, "Cleared Signal username");
} else if !tranquil_pds::comms::is_valid_signal_username(&signal_clean) {
return Err(ApiError::InvalidRequest(
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)"
.into(),
));
} else {
state
.user_repo
.set_unverified_signal(user_id, &signal_clean)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
request_channel_verification(
&state,
user_id,
&auth.did,
CommsChannel::Signal,
&signal_clean,
None,
)
.await?;
verification_required.push(CommsChannel::Signal);
info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username");
}
process_messaging_channel_update(
&state,
user_id,
&auth.did,
CommsChannel::Signal,
signal,
effective_channel,
&mut verification_required,
)
.await?;
}
Ok(Json(UpdateNotificationPrefsResponse {
Ok(Json(UpdateNotificationPrefsOutput {
success: true,
verification_required,
})
.into_response())
}))
}
+57 -79
View File
@@ -12,8 +12,11 @@ use subtle::ConstantTimeEq;
use tracing::{error, info, warn};
use tranquil_db_traits::CommsChannel;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
use tranquil_pds::api::{
EmailUpdateStatusOutput, EmptyResponse, InUseOutput, TokenRequiredResponse, VerifiedResponse,
};
use tranquil_pds::auth::{Auth, NotTakendown};
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
use tranquil_pds::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
use tranquil_pds::state::AppState;
@@ -48,15 +51,8 @@ pub async fn request_email_update(
_rate_limit: RateLimited<EmailUpdateLimit>,
auth: Auth<NotTakendown>,
input: Option<Json<RequestEmailUpdateInput>>,
) -> Result<Response, ApiError> {
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
&auth.auth_source,
auth.scope.as_deref(),
tranquil_pds::oauth::scopes::AccountAttr::Email,
tranquil_pds::oauth::scopes::AccountAction::Manage,
) {
return Ok(e);
}
) -> Result<Json<TokenRequiredResponse>, ApiError> {
auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?;
let user = state
.user_repo
@@ -119,7 +115,7 @@ pub async fn request_email_update(
}
info!("Email update requested for user {}", user.id);
Ok(TokenRequiredResponse::response(token_required).into_response())
Ok(Json(TokenRequiredResponse { token_required }))
}
#[derive(Deserialize)]
@@ -134,15 +130,8 @@ pub async fn confirm_email(
_rate_limit: RateLimited<EmailUpdateLimit>,
auth: Auth<NotTakendown>,
Json(input): Json<ConfirmEmailInput>,
) -> Result<Response, ApiError> {
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
&auth.auth_source,
auth.scope.as_deref(),
tranquil_pds::oauth::scopes::AccountAttr::Email,
tranquil_pds::oauth::scopes::AccountAction::Manage,
) {
return Ok(e);
}
) -> Result<Json<EmptyResponse>, ApiError> {
auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?;
let did = &auth.did;
let user = state
@@ -163,7 +152,7 @@ pub async fn confirm_email(
}
if user.email_verified {
return Ok(EmptyResponse::ok().into_response());
return Ok(Json(EmptyResponse {}));
}
let confirmation_code =
@@ -196,7 +185,7 @@ pub async fn confirm_email(
.log_db_err("confirming email")?;
info!("Email confirmed for user {}", user.id);
Ok(EmptyResponse::ok().into_response())
Ok(Json(EmptyResponse {}))
}
#[derive(Deserialize)]
@@ -212,15 +201,8 @@ pub async fn update_email(
State(state): State<AppState>,
auth: Auth<NotTakendown>,
Json(input): Json<UpdateEmailInput>,
) -> Result<Response, ApiError> {
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
&auth.auth_source,
auth.scope.as_deref(),
tranquil_pds::oauth::scopes::AccountAttr::Email,
tranquil_pds::oauth::scopes::AccountAction::Manage,
) {
return Ok(e);
}
) -> Result<Json<EmptyResponse>, ApiError> {
auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?;
let did = &auth.did;
let user = state
@@ -279,7 +261,7 @@ pub async fn update_email(
ApiError::InternalError(Some("Failed to update 2FA setting".into()))
})?;
}
return Ok(EmptyResponse::ok().into_response());
return Ok(Json(EmptyResponse {}));
}
if email_verified {
@@ -394,7 +376,7 @@ pub async fn update_email(
}
info!("Email updated for user {}", user_id);
Ok(EmptyResponse::ok().into_response())
Ok(Json(EmptyResponse {}))
}
#[derive(Deserialize)]
@@ -406,19 +388,18 @@ pub async fn check_email_verified(
State(state): State<AppState>,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckEmailVerifiedInput>,
) -> Response {
match state
) -> Result<Json<VerifiedResponse>, ApiError> {
let verified = state
.user_repo
.check_email_verified_by_identifier(&input.identifier)
.await
{
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
.map_err(|e| {
error!("DB error checking email verified: {:?}", e);
ApiError::InternalError(None).into_response()
}
}
ApiError::InternalError(None)
})?
.ok_or(ApiError::AccountNotFound)?;
Ok(Json(VerifiedResponse { verified }))
}
#[derive(Deserialize)]
@@ -431,19 +412,18 @@ pub async fn check_channel_verified(
State(state): State<AppState>,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckChannelVerifiedInput>,
) -> Response {
match state
) -> Result<Json<VerifiedResponse>, ApiError> {
let verified = state
.user_repo
.check_channel_verified_by_did(&input.did, input.channel)
.await
{
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
.map_err(|e| {
error!("DB error checking channel verified: {:?}", e);
ApiError::InternalError(None).into_response()
}
}
ApiError::InternalError(None)
})?
.ok_or(ApiError::AccountNotFound)?;
Ok(Json(VerifiedResponse { verified }))
}
#[derive(Deserialize)]
@@ -545,37 +525,37 @@ pub async fn check_email_update_status(
State(state): State<AppState>,
_rate_limit: RateLimited<VerificationCheckLimit>,
auth: Auth<NotTakendown>,
) -> Result<Response, ApiError> {
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
&auth.auth_source,
auth.scope.as_deref(),
tranquil_pds::oauth::scopes::AccountAttr::Email,
tranquil_pds::oauth::scopes::AccountAction::Read,
) {
return Ok(e);
}
) -> Result<Json<EmailUpdateStatusOutput>, ApiError> {
auth.check_account_scope(AccountAttr::Email, AccountAction::Read)?;
let cache_key = email_update_cache_key(&auth.did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
None => {
return Ok(Json(json!({ "pending": false, "authorized": false })).into_response());
return Ok(Json(EmailUpdateStatusOutput {
pending: false,
authorized: false,
new_email: None,
}));
}
};
let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return Ok(Json(json!({ "pending": false, "authorized": false })).into_response());
return Ok(Json(EmailUpdateStatusOutput {
pending: false,
authorized: false,
new_email: None,
}));
}
};
Ok(Json(json!({
"pending": true,
"authorized": pending.authorized,
"newEmail": pending.new_email,
Ok(Json(EmailUpdateStatusOutput {
pending: true,
authorized: pending.authorized,
new_email: Some(pending.new_email),
}))
.into_response())
}
#[derive(Deserialize)]
@@ -587,22 +567,20 @@ pub async fn check_email_in_use(
State(state): State<AppState>,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckEmailInUseInput>,
) -> Response {
) -> Result<Json<InUseOutput>, ApiError> {
let email = input.email.trim().to_lowercase();
if email.is_empty() {
return ApiError::InvalidRequest("email is required".into()).into_response();
return Err(ApiError::InvalidRequest("email is required".into()));
}
let count = match state.user_repo.count_accounts_by_email(&email).await {
Ok(c) => c,
Err(e) => {
let count = state
.user_repo
.count_accounts_by_email(&email)
.await
.map_err(|e| {
error!("DB error checking email usage: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
ApiError::InternalError(None)
})?;
Json(json!({
"inUse": count > 0,
}))
.into_response()
Ok(Json(InUseOutput { in_use: count > 0 }))
}
+74 -46
View File
@@ -1,11 +1,11 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json;
use serde::Serialize;
use tranquil_db_traits::CommsChannel;
use tranquil_pds::BUILD_VERSION;
use tranquil_pds::state::AppState;
use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username};
fn get_available_comms_channels() -> Vec<tranquil_db_traits::CommsChannel> {
use tranquil_db_traits::CommsChannel;
fn get_available_comms_channels() -> Vec<CommsChannel> {
let cfg = tranquil_config::get();
let mut channels = vec![CommsChannel::Email];
if cfg.discord.bot_token.is_some() {
@@ -31,59 +31,87 @@ pub fn is_self_hosted_did_web_enabled() -> bool {
tranquil_config::get().server.enable_pds_hosted_did_web
}
pub async fn describe_server() -> impl IntoResponse {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DescribeServerLinks {
#[serde(skip_serializing_if = "Option::is_none")]
pub privacy_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terms_of_service: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DescribeServerContact {
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DescribeServerOutput {
pub available_user_domains: Vec<String>,
pub invite_code_required: bool,
pub did: String,
pub links: DescribeServerLinks,
pub contact: DescribeServerContact,
pub version: &'static str,
pub available_comms_channels: Vec<CommsChannel>,
pub self_hosted_did_web_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub discord_bot_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discord_app_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub telegram_bot_username: Option<String>,
}
pub async fn describe_server() -> Json<DescribeServerOutput> {
let cfg = tranquil_config::get();
let pds_hostname = &cfg.server.hostname;
let domains = cfg.server.user_handle_domain_list();
let invite_code_required = cfg.server.invite_code_required;
let privacy_policy = cfg.server.privacy_policy_url.clone();
let terms_of_service = cfg.server.terms_of_service_url.clone();
let contact_email = cfg.server.contact_email.clone();
let mut links = serde_json::Map::new();
if let Some(pp) = privacy_policy {
links.insert("privacyPolicy".to_string(), json!(pp));
}
if let Some(tos) = terms_of_service {
links.insert("termsOfService".to_string(), json!(tos));
}
let mut contact = serde_json::Map::new();
if let Some(email) = contact_email {
contact.insert("email".to_string(), json!(email));
}
let mut response = json!({
"availableUserDomains": domains,
"inviteCodeRequired": invite_code_required,
"did": format!("did:web:{}", pds_hostname),
"links": links,
"contact": contact,
"version": BUILD_VERSION,
"availableCommsChannels": get_available_comms_channels(),
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
});
if let Some(bot_username) = discord_bot_username() {
response["discordBotUsername"] = json!(bot_username);
}
if let Some(app_id) = discord_app_id() {
response["discordAppId"] = json!(app_id);
}
if let Some(bot_username) = telegram_bot_username() {
response["telegramBotUsername"] = json!(bot_username);
}
Json(response)
Json(DescribeServerOutput {
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 {
privacy_policy: cfg.server.privacy_policy_url.clone(),
terms_of_service: cfg.server.terms_of_service_url.clone(),
},
contact: DescribeServerContact {
email: cfg.server.contact_email.clone(),
},
version: BUILD_VERSION,
available_comms_channels: get_available_comms_channels(),
self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(),
discord_bot_username: discord_bot_username().map(String::from),
discord_app_id: discord_app_id().map(String::from),
telegram_bot_username: telegram_bot_username().map(String::from),
})
}
#[derive(Serialize)]
pub struct HealthOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<&'static str>,
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
match state.infra_repo.health_check().await {
Ok(true) => (
StatusCode::OK,
Json(json!({
"version": format!("tranquil {}", BUILD_VERSION)
})),
Json(HealthOutput {
version: Some(format!("tranquil {}", BUILD_VERSION)),
error: None,
}),
),
_ => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "Service Unavailable"
})),
Json(HealthOutput {
version: None,
error: Some("Service Unavailable"),
}),
),
}
}