Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

DRAFT: Better code quality via type safety #5

merged opened by lewis.moe targeting main from fix/code-quality-in-general

Ensuring at compile-time that we're definitely handling possible early failures in functions

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mdbo7zq5ae22
+2706 -3322
Interdiff #0 #1
+76 -32
crates/tranquil-db/src/postgres/oauth.rs
··· 4 use sqlx::PgPool; 5 use tranquil_db_traits::{ 6 DbError, DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, 7 - ScopePreference, TrustedDeviceRow, TwoFactorChallenge, 8 }; 9 use tranquil_oauth::{ 10 AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, Code as OAuthCode, 11 - DeviceData, DeviceId as OAuthDeviceId, RequestData, SessionId as OAuthSessionId, TokenData, 12 - TokenId as OAuthTokenId, RefreshToken as OAuthRefreshToken, 13 }; 14 use tranquil_types::{ 15 AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId, ··· 49 50 #[async_trait] 51 impl OAuthRepository for PostgresOAuthRepository { 52 - async fn create_token(&self, data: &TokenData) -> Result<i32, DbError> { 53 let client_auth_json = to_json(&data.client_auth)?; 54 let parameters_json = to_json(&data.parameters)?; 55 let row = sqlx::query!( ··· 78 .fetch_one(&self.pool) 79 .await 80 .map_err(map_sqlx_error)?; 81 - Ok(row.id) 82 } 83 84 async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError> { ··· 96 .map_err(map_sqlx_error)?; 97 match row { 98 Some(r) => Ok(Some(TokenData { 99 - did: r.did.parse().map_err(|_| DbError::Other("Invalid DID in token".into()))?, 100 token_id: OAuthTokenId(r.token_id), 101 created_at: r.created_at, 102 updated_at: r.updated_at, ··· 109 code: r.code.map(OAuthCode), 110 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 111 scope: r.scope, 112 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID".into()))?, 113 })), 114 None => Ok(None), 115 } ··· 118 async fn get_token_by_refresh_token( 119 &self, 120 refresh_token: &RefreshToken, 121 - ) -> Result<Option<(i32, TokenData)>, DbError> { 122 let row = sqlx::query!( 123 r#" 124 SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, ··· 133 .map_err(map_sqlx_error)?; 134 match row { 135 Some(r) => Ok(Some(( 136 - r.id, 137 TokenData { 138 - did: r.did.parse().map_err(|_| DbError::Other("Invalid DID in token".into()))?, 139 token_id: OAuthTokenId(r.token_id), 140 created_at: r.created_at, 141 updated_at: r.updated_at, ··· 148 code: r.code.map(OAuthCode), 149 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 150 scope: r.scope, 151 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID".into()))?, 152 }, 153 ))), 154 None => Ok(None), ··· 158 async fn get_token_by_previous_refresh_token( 159 &self, 160 refresh_token: &RefreshToken, 161 - ) -> Result<Option<(i32, TokenData)>, DbError> { 162 let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS); 163 let row = sqlx::query!( 164 r#" ··· 175 .map_err(map_sqlx_error)?; 176 match row { 177 Some(r) => Ok(Some(( 178 - r.id, 179 TokenData { 180 - did: r.did.parse().map_err(|_| DbError::Other("Invalid DID in token".into()))?, 181 token_id: OAuthTokenId(r.token_id), 182 created_at: r.created_at, 183 updated_at: r.updated_at, ··· 190 code: r.code.map(OAuthCode), 191 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 192 scope: r.scope, 193 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID".into()))?, 194 }, 195 ))), 196 None => Ok(None), ··· 199 200 async fn rotate_token( 201 &self, 202 - old_db_id: i32, 203 new_refresh_token: &RefreshToken, 204 new_expires_at: DateTime<Utc>, 205 ) -> Result<(), DbError> { ··· 208 r#" 209 SELECT current_refresh_token FROM oauth_token WHERE id = $1 210 "#, 211 - old_db_id 212 ) 213 .fetch_one(&mut *tx) 214 .await ··· 220 VALUES ($1, $2) 221 "#, 222 old_rt, 223 - old_db_id 224 ) 225 .execute(&mut *tx) 226 .await ··· 233 previous_refresh_token = $4, rotated_at = NOW() 234 WHERE id = $1 235 "#, 236 - old_db_id, 237 new_refresh_token.as_str(), 238 new_expires_at, 239 old_refresh ··· 248 async fn check_refresh_token_used( 249 &self, 250 refresh_token: &RefreshToken, 251 - ) -> Result<Option<i32>, DbError> { 252 let row = sqlx::query_scalar!( 253 r#" 254 SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 ··· 258 .fetch_optional(&self.pool) 259 .await 260 .map_err(map_sqlx_error)?; 261 - Ok(row) 262 } 263 264 async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError> { ··· 274 Ok(()) 275 } 276 277 - async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError> { 278 sqlx::query!( 279 r#" 280 DELETE FROM oauth_token WHERE id = $1 281 "#, 282 - db_id 283 ) 284 .execute(&self.pool) 285 .await ··· 303 rows.into_iter() 304 .map(|r| { 305 Ok(TokenData { 306 - did: r.did.parse().map_err(|_| DbError::Other("Invalid DID in token".into()))?, 307 token_id: OAuthTokenId(r.token_id), 308 created_at: r.created_at, 309 updated_at: r.updated_at, ··· 316 code: r.code.map(OAuthCode), 317 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 318 scope: r.scope, 319 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID".into()))?, 320 }) 321 }) 322 .collect() ··· 449 client_auth, 450 parameters, 451 expires_at: r.expires_at, 452 - did: r.did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid DID in DB".into()))?, 453 device_id: r.device_id.map(OAuthDeviceId), 454 code: r.code.map(OAuthCode), 455 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?, 456 })) 457 } 458 None => Ok(None), ··· 535 client_auth, 536 parameters, 537 expires_at: r.expires_at, 538 - did: r.did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid DID in DB".into()))?, 539 device_id: r.device_id.map(OAuthDeviceId), 540 code: r.code.map(OAuthCode), 541 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?, 542 })) 543 } 544 None => Ok(None), ··· 1208 Ok(rows 1209 .into_iter() 1210 .map(|r| OAuthSessionListItem { 1211 - id: r.id, 1212 token_id: TokenId::from(r.token_id), 1213 created_at: r.created_at, 1214 expires_at: r.expires_at, ··· 1217 .collect()) 1218 } 1219 1220 - async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result<u64, DbError> { 1221 let result = sqlx::query!( 1222 "DELETE FROM oauth_token WHERE id = $1 AND did = $2", 1223 - session_id, 1224 did.as_str() 1225 ) 1226 .execute(&self.pool)
··· 4 use sqlx::PgPool; 5 use tranquil_db_traits::{ 6 DbError, DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, 7 + ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, 8 }; 9 use tranquil_oauth::{ 10 AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, Code as OAuthCode, 11 + DeviceData, DeviceId as OAuthDeviceId, RefreshToken as OAuthRefreshToken, RequestData, 12 + SessionId as OAuthSessionId, TokenData, TokenId as OAuthTokenId, 13 }; 14 use tranquil_types::{ 15 AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId, ··· 49 50 #[async_trait] 51 impl OAuthRepository for PostgresOAuthRepository { 52 + async fn create_token(&self, data: &TokenData) -> Result<TokenFamilyId, DbError> { 53 let client_auth_json = to_json(&data.client_auth)?; 54 let parameters_json = to_json(&data.parameters)?; 55 let row = sqlx::query!( ··· 78 .fetch_one(&self.pool) 79 .await 80 .map_err(map_sqlx_error)?; 81 + Ok(TokenFamilyId::new(row.id)) 82 } 83 84 async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError> { ··· 96 .map_err(map_sqlx_error)?; 97 match row { 98 Some(r) => Ok(Some(TokenData { 99 + did: r 100 + .did 101 + .parse() 102 + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, 103 token_id: OAuthTokenId(r.token_id), 104 created_at: r.created_at, 105 updated_at: r.updated_at, ··· 112 code: r.code.map(OAuthCode), 113 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 114 scope: r.scope, 115 + controller_did: r 116 + .controller_did 117 + .map(|s| s.parse()) 118 + .transpose() 119 + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, 120 })), 121 None => Ok(None), 122 } ··· 125 async fn get_token_by_refresh_token( 126 &self, 127 refresh_token: &RefreshToken, 128 + ) -> Result<Option<(TokenFamilyId, TokenData)>, DbError> { 129 let row = sqlx::query!( 130 r#" 131 SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, ··· 140 .map_err(map_sqlx_error)?; 141 match row { 142 Some(r) => Ok(Some(( 143 + TokenFamilyId::new(r.id), 144 TokenData { 145 + did: r 146 + .did 147 + .parse() 148 + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, 149 token_id: OAuthTokenId(r.token_id), 150 created_at: r.created_at, 151 updated_at: r.updated_at, ··· 158 code: r.code.map(OAuthCode), 159 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 160 scope: r.scope, 161 + controller_did: r 162 + .controller_did 163 + .map(|s| s.parse()) 164 + .transpose() 165 + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, 166 }, 167 ))), 168 None => Ok(None), ··· 172 async fn get_token_by_previous_refresh_token( 173 &self, 174 refresh_token: &RefreshToken, 175 + ) -> Result<Option<(TokenFamilyId, TokenData)>, DbError> { 176 let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS); 177 let row = sqlx::query!( 178 r#" ··· 189 .map_err(map_sqlx_error)?; 190 match row { 191 Some(r) => Ok(Some(( 192 + TokenFamilyId::new(r.id), 193 TokenData { 194 + did: r 195 + .did 196 + .parse() 197 + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, 198 token_id: OAuthTokenId(r.token_id), 199 created_at: r.created_at, 200 updated_at: r.updated_at, ··· 207 code: r.code.map(OAuthCode), 208 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 209 scope: r.scope, 210 + controller_did: r 211 + .controller_did 212 + .map(|s| s.parse()) 213 + .transpose() 214 + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, 215 }, 216 ))), 217 None => Ok(None), ··· 220 221 async fn rotate_token( 222 &self, 223 + old_db_id: TokenFamilyId, 224 new_refresh_token: &RefreshToken, 225 new_expires_at: DateTime<Utc>, 226 ) -> Result<(), DbError> { ··· 229 r#" 230 SELECT current_refresh_token FROM oauth_token WHERE id = $1 231 "#, 232 + old_db_id.as_i32() 233 ) 234 .fetch_one(&mut *tx) 235 .await ··· 241 VALUES ($1, $2) 242 "#, 243 old_rt, 244 + old_db_id.as_i32() 245 ) 246 .execute(&mut *tx) 247 .await ··· 254 previous_refresh_token = $4, rotated_at = NOW() 255 WHERE id = $1 256 "#, 257 + old_db_id.as_i32(), 258 new_refresh_token.as_str(), 259 new_expires_at, 260 old_refresh ··· 269 async fn check_refresh_token_used( 270 &self, 271 refresh_token: &RefreshToken, 272 + ) -> Result<Option<TokenFamilyId>, DbError> { 273 let row = sqlx::query_scalar!( 274 r#" 275 SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 ··· 279 .fetch_optional(&self.pool) 280 .await 281 .map_err(map_sqlx_error)?; 282 + Ok(row.map(TokenFamilyId::new)) 283 } 284 285 async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError> { ··· 295 Ok(()) 296 } 297 298 + async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError> { 299 sqlx::query!( 300 r#" 301 DELETE FROM oauth_token WHERE id = $1 302 "#, 303 + db_id.as_i32() 304 ) 305 .execute(&self.pool) 306 .await ··· 324 rows.into_iter() 325 .map(|r| { 326 Ok(TokenData { 327 + did: r 328 + .did 329 + .parse() 330 + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, 331 token_id: OAuthTokenId(r.token_id), 332 created_at: r.created_at, 333 updated_at: r.updated_at, ··· 340 code: r.code.map(OAuthCode), 341 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 342 scope: r.scope, 343 + controller_did: r 344 + .controller_did 345 + .map(|s| s.parse()) 346 + .transpose() 347 + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, 348 }) 349 }) 350 .collect() ··· 477 client_auth, 478 parameters, 479 expires_at: r.expires_at, 480 + did: r 481 + .did 482 + .map(|s| s.parse()) 483 + .transpose() 484 + .map_err(|_| DbError::Other("Invalid DID in DB".into()))?, 485 device_id: r.device_id.map(OAuthDeviceId), 486 code: r.code.map(OAuthCode), 487 + controller_did: r 488 + .controller_did 489 + .map(|s| s.parse()) 490 + .transpose() 491 + .map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?, 492 })) 493 } 494 None => Ok(None), ··· 571 client_auth, 572 parameters, 573 expires_at: r.expires_at, 574 + did: r 575 + .did 576 + .map(|s| s.parse()) 577 + .transpose() 578 + .map_err(|_| DbError::Other("Invalid DID in DB".into()))?, 579 device_id: r.device_id.map(OAuthDeviceId), 580 code: r.code.map(OAuthCode), 581 + controller_did: r 582 + .controller_did 583 + .map(|s| s.parse()) 584 + .transpose() 585 + .map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?, 586 })) 587 } 588 None => Ok(None), ··· 1252 Ok(rows 1253 .into_iter() 1254 .map(|r| OAuthSessionListItem { 1255 + id: TokenFamilyId::new(r.id), 1256 token_id: TokenId::from(r.token_id), 1257 created_at: r.created_at, 1258 expires_at: r.expires_at, ··· 1261 .collect()) 1262 } 1263 1264 + async fn delete_session_by_id(&self, session_id: TokenFamilyId, did: &Did) -> Result<u64, DbError> { 1265 let result = sqlx::query!( 1266 "DELETE FROM oauth_token WHERE id = $1 AND did = $2", 1267 + session_id.as_i32(), 1268 did.as_str() 1269 ) 1270 .execute(&self.pool)
crates/tranquil-oauth/src/lib.rs

This file has not been changed.

+1 -7
crates/tranquil-oauth/src/types.rs
··· 317 pub keys: Vec<JwkPublicKey>, 318 } 319 320 - 321 #[derive(Debug, Clone)] 322 pub struct FlowPending { 323 pub parameters: AuthorizationRequestParameters, ··· 460 } 461 } 462 463 - 464 #[derive(Debug, Clone, PartialEq, Eq)] 465 pub enum RefreshTokenState { 466 Valid, ··· 584 fn test_auth_flow_authorized() { 585 let did = test_did("did:plc:test"); 586 let code = test_code("auth-code-123"); 587 - let data = make_request_data( 588 - Some(did.clone()), 589 - Some(code.clone()), 590 - Duration::minutes(5), 591 - ); 592 let flow = AuthFlow::from_request_data(data).expect("should not be expired"); 593 assert!(matches!(flow, AuthFlow::Authorized(_))); 594 let with_user = flow.clone().require_user().expect("should have user");
··· 317 pub keys: Vec<JwkPublicKey>, 318 } 319 320 #[derive(Debug, Clone)] 321 pub struct FlowPending { 322 pub parameters: AuthorizationRequestParameters, ··· 459 } 460 } 461 462 #[derive(Debug, Clone, PartialEq, Eq)] 463 pub enum RefreshTokenState { 464 Valid, ··· 582 fn test_auth_flow_authorized() { 583 let did = test_did("did:plc:test"); 584 let code = test_code("auth-code-123"); 585 + let data = make_request_data(Some(did.clone()), Some(code.clone()), Duration::minutes(5)); 586 let flow = AuthFlow::from_request_data(data).expect("should not be expired"); 587 assert!(matches!(flow, AuthFlow::Authorized(_))); 588 let with_user = flow.clone().require_user().expect("should have user");
+6 -2
crates/tranquil-pds/src/api/admin/account/delete.rs
··· 36 .await 37 .log_db_err("deleting account")?; 38 39 - if let Err(e) = 40 - crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await 41 { 42 warn!( 43 "Failed to sequence account deletion event for {}: {}",
··· 36 .await 37 .log_db_err("deleting account")?; 38 39 + if let Err(e) = crate::api::repo::record::sequence_account_event( 40 + &state, 41 + did, 42 + tranquil_db_traits::AccountStatus::Deleted, 43 + ) 44 + .await 45 { 46 warn!( 47 "Failed to sequence account deletion event for {}: {}",
+1 -1
crates/tranquil-pds/src/api/admin/account/email.rs
··· 1 use crate::api::error::{ApiError, AtpJson, DbResultExt}; 2 use crate::auth::{Admin, Auth}; 3 use crate::state::AppState; 4 - use crate::util::pds_hostname; 5 use crate::types::Did; 6 use axum::{ 7 Json, 8 extract::State,
··· 1 use crate::api::error::{ApiError, AtpJson, DbResultExt}; 2 use crate::auth::{Admin, Auth}; 3 use crate::state::AppState; 4 use crate::types::Did; 5 + use crate::util::pds_hostname; 6 use axum::{ 7 Json, 8 extract::State,
+3 -3
crates/tranquil-pds/src/api/admin/account/info.rs
··· 149 .map(|ic| InviteCodeInfo { 150 code: ic.code.clone(), 151 available: ic.available_uses, 152 - disabled: ic.disabled, 153 for_account: ic.for_account, 154 created_by: ic.created_by, 155 created_at: ic.created_at.to_rfc3339(), ··· 177 Some(InviteCodeInfo { 178 code: info.code, 179 available: info.available_uses, 180 - disabled: info.disabled, 181 for_account: info.for_account, 182 created_by: info.created_by, 183 created_at: info.created_at.to_rfc3339(), ··· 265 let info = InviteCodeInfo { 266 code: ic.code.clone(), 267 available: ic.available_uses, 268 - disabled: ic.disabled, 269 for_account: ic.for_account, 270 created_by: ic.created_by, 271 created_at: ic.created_at.to_rfc3339(),
··· 149 .map(|ic| InviteCodeInfo { 150 code: ic.code.clone(), 151 available: ic.available_uses, 152 + disabled: ic.state.is_disabled(), 153 for_account: ic.for_account, 154 created_by: ic.created_by, 155 created_at: ic.created_at.to_rfc3339(), ··· 177 Some(InviteCodeInfo { 178 code: info.code, 179 available: info.available_uses, 180 + disabled: info.state.is_disabled(), 181 for_account: info.for_account, 182 created_by: info.created_by, 183 created_at: info.created_at.to_rfc3339(), ··· 265 let info = InviteCodeInfo { 266 code: ic.code.clone(), 267 available: ic.available_uses, 268 + disabled: ic.state.is_disabled(), 269 for_account: ic.for_account, 270 created_by: ic.created_by, 271 created_at: ic.created_at.to_rfc3339(),
crates/tranquil-pds/src/api/admin/account/search.rs

This file has not been changed.

+1 -1
crates/tranquil-pds/src/api/admin/account/update.rs
··· 2 use crate::api::error::ApiError; 3 use crate::auth::{Admin, Auth}; 4 use crate::state::AppState; 5 - use crate::util::pds_hostname_without_port; 6 use crate::types::{Did, Handle, PlainPassword}; 7 use axum::{ 8 Json, 9 extract::State,
··· 2 use crate::api::error::ApiError; 3 use crate::auth::{Admin, Auth}; 4 use crate::state::AppState; 5 use crate::types::{Did, Handle, PlainPassword}; 6 + use crate::util::pds_hostname_without_port; 7 use axum::{ 8 Json, 9 extract::State,
crates/tranquil-pds/src/api/admin/config.rs

This file has not been changed.

+1 -1
crates/tranquil-pds/src/api/admin/invite.rs
··· 135 InviteCodeInfo { 136 code: r.code.clone(), 137 available: r.available_uses, 138 - disabled: r.disabled.unwrap_or(false), 139 for_account: creator_did.clone(), 140 created_by: creator_did, 141 created_at: r.created_at.to_rfc3339(),
··· 135 InviteCodeInfo { 136 code: r.code.clone(), 137 available: r.available_uses, 138 + disabled: r.state().is_disabled(), 139 for_account: creator_did.clone(), 140 created_by: creator_did, 141 created_at: r.created_at.to_rfc3339(),
crates/tranquil-pds/src/api/age_assurance.rs

This file has not been changed.

+48 -48
crates/tranquil-pds/src/api/delegation.rs
··· 2 use crate::api::repo::record::utils::create_signed_commit; 3 use crate::auth::{Active, Auth}; 4 use crate::delegation::{ 5 - DelegationActionType, SCOPE_PRESETS, scopes, verify_can_add_controllers, 6 verify_can_be_controller, verify_can_control_accounts, 7 }; 8 use crate::rate_limit::{AccountCreationLimit, RateLimited}; ··· 61 .map(|c| ControllerInfo { 62 did: c.did, 63 handle: c.handle, 64 - granted_scopes: c.granted_scopes, 65 granted_at: c.granted_at, 66 is_active: c.is_active, 67 }) ··· 73 #[derive(Debug, Deserialize)] 74 pub struct AddControllerInput { 75 pub controller_did: Did, 76 - pub granted_scopes: String, 77 } 78 79 pub async fn add_controller( ··· 81 auth: Auth<Active>, 82 Json(input): Json<AddControllerInput>, 83 ) -> Result<Response, ApiError> { 84 - if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) { 85 - return Ok(ApiError::InvalidScopes(e).into_response()); 86 - } 87 - 88 let controller_exists = state 89 .user_repo 90 .get_by_did(&input.controller_did) ··· 97 return Ok(ApiError::ControllerNotFound.into_response()); 98 } 99 100 - let _can_add = match verify_can_add_controllers(&state, &auth).await { 101 Ok(proof) => proof, 102 Err(response) => return Ok(response), 103 }; 104 105 - if let Err(response) = verify_can_be_controller(&state, &input.controller_did).await { 106 - return Ok(response); 107 - } 108 109 match state 110 .delegation_repo 111 .create_delegation( 112 - &auth.did, 113 - &input.controller_did, 114 &input.granted_scopes, 115 - &auth.did, 116 ) 117 .await 118 { ··· 120 let _ = state 121 .delegation_repo 122 .log_delegation_action( 123 - &auth.did, 124 - &auth.did, 125 - Some(&input.controller_did), 126 DelegationActionType::GrantCreated, 127 Some(serde_json::json!({ 128 - "granted_scopes": input.granted_scopes 129 })), 130 None, 131 None, ··· 210 #[derive(Debug, Deserialize)] 211 pub struct UpdateControllerScopesInput { 212 pub controller_did: Did, 213 - pub granted_scopes: String, 214 } 215 216 pub async fn update_controller_scopes( ··· 218 auth: Auth<Active>, 219 Json(input): Json<UpdateControllerScopesInput>, 220 ) -> Result<Response, ApiError> { 221 - if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) { 222 - return Ok(ApiError::InvalidScopes(e).into_response()); 223 - } 224 - 225 match state 226 .delegation_repo 227 - .update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes) 228 .await 229 { 230 Ok(true) => { ··· 236 Some(&input.controller_did), 237 DelegationActionType::ScopesModified, 238 Some(serde_json::json!({ 239 - "new_scopes": input.granted_scopes 240 })), 241 None, 242 None, ··· 301 .map(|a| DelegatedAccountInfo { 302 did: a.did, 303 handle: a.handle, 304 - granted_scopes: a.granted_scopes, 305 granted_at: a.granted_at, 306 }) 307 .collect(), ··· 418 pub struct CreateDelegatedAccountInput { 419 pub handle: String, 420 pub email: Option<String>, 421 - pub controller_scopes: String, 422 pub invite_code: Option<String>, 423 } 424 ··· 435 auth: Auth<Active>, 436 Json(input): Json<CreateDelegatedAccountInput>, 437 ) -> Result<Response, ApiError> { 438 - if let Err(e) = scopes::validate_delegation_scopes(&input.controller_scopes) { 439 - return Ok(ApiError::InvalidScopes(e).into_response()); 440 - } 441 - 442 - let _can_control = match verify_can_control_accounts(&state, &auth).await { 443 Ok(proof) => proof, 444 Err(response) => return Ok(response), 445 }; ··· 478 return Ok(ApiError::InvalidEmail.into_response()); 479 } 480 481 - if let Some(ref code) = input.invite_code { 482 - let valid = state 483 - .infra_repo 484 - .is_invite_code_valid(code) 485 - .await 486 - .unwrap_or(false); 487 - 488 - if !valid { 489 - return Ok(ApiError::InvalidInviteCode.into_response()); 490 } 491 } else { 492 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 495 if invite_required { 496 return Ok(ApiError::InviteCodeRequired.into_response()); 497 } 498 - } 499 500 use k256::ecdsa::SigningKey; 501 use rand::rngs::OsRng; ··· 546 547 let did = Did::new_unchecked(&genesis_result.did); 548 let handle = Handle::new_unchecked(&handle); 549 - info!(did = %did, handle = %handle, controller = %&auth.did, "Created DID for delegated account"); 550 551 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 552 Ok(bytes) => bytes, ··· 586 handle: handle.clone(), 587 email: email.clone(), 588 did: did.clone(), 589 - controller_did: auth.did.clone(), 590 - controller_scopes: input.controller_scopes.clone(), 591 encrypted_key_bytes, 592 encryption_version: crate::config::ENCRYPTION_VERSION, 593 commit_cid: commit_cid.to_string(), ··· 596 invite_code: input.invite_code.clone(), 597 }; 598 599 - let _user_id = match state 600 .user_repo 601 .create_delegated_account(&create_input) 602 .await ··· 614 } 615 }; 616 617 if let Err(e) = 618 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 619 { 620 warn!("Failed to sequence identity event for {}: {}", did, e); 621 } 622 - if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 623 { 624 warn!("Failed to sequence account event for {}: {}", did, e); 625 } ··· 651 DelegationActionType::GrantCreated, 652 Some(json!({ 653 "account_created": true, 654 - "granted_scopes": input.controller_scopes 655 })), 656 None, 657 None,
··· 2 use crate::api::repo::record::utils::create_signed_commit; 3 use crate::auth::{Active, Auth}; 4 use crate::delegation::{ 5 + DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers, 6 verify_can_be_controller, verify_can_control_accounts, 7 }; 8 use crate::rate_limit::{AccountCreationLimit, RateLimited}; ··· 61 .map(|c| ControllerInfo { 62 did: c.did, 63 handle: c.handle, 64 + granted_scopes: c.granted_scopes.into_string(), 65 granted_at: c.granted_at, 66 is_active: c.is_active, 67 }) ··· 73 #[derive(Debug, Deserialize)] 74 pub struct AddControllerInput { 75 pub controller_did: Did, 76 + pub granted_scopes: ValidatedDelegationScope, 77 } 78 79 pub async fn add_controller( ··· 81 auth: Auth<Active>, 82 Json(input): Json<AddControllerInput>, 83 ) -> Result<Response, ApiError> { 84 let controller_exists = state 85 .user_repo 86 .get_by_did(&input.controller_did) ··· 93 return Ok(ApiError::ControllerNotFound.into_response()); 94 } 95 96 + let can_add = match verify_can_add_controllers(&state, &auth).await { 97 Ok(proof) => proof, 98 Err(response) => return Ok(response), 99 }; 100 101 + let can_be_controller = match verify_can_be_controller(&state, &input.controller_did).await { 102 + Ok(proof) => proof, 103 + Err(response) => return Ok(response), 104 + }; 105 106 match state 107 .delegation_repo 108 .create_delegation( 109 + can_add.did(), 110 + can_be_controller.did(), 111 &input.granted_scopes, 112 + can_add.did(), 113 ) 114 .await 115 { ··· 117 let _ = state 118 .delegation_repo 119 .log_delegation_action( 120 + can_add.did(), 121 + can_add.did(), 122 + Some(can_be_controller.did()), 123 DelegationActionType::GrantCreated, 124 Some(serde_json::json!({ 125 + "granted_scopes": input.granted_scopes.as_str() 126 })), 127 None, 128 None, ··· 207 #[derive(Debug, Deserialize)] 208 pub struct UpdateControllerScopesInput { 209 pub controller_did: Did, 210 + pub granted_scopes: ValidatedDelegationScope, 211 } 212 213 pub async fn update_controller_scopes( ··· 215 auth: Auth<Active>, 216 Json(input): Json<UpdateControllerScopesInput>, 217 ) -> Result<Response, ApiError> { 218 match state 219 .delegation_repo 220 + .update_delegation_scopes( 221 + &auth.did, 222 + &input.controller_did, 223 + &input.granted_scopes, 224 + ) 225 .await 226 { 227 Ok(true) => { ··· 233 Some(&input.controller_did), 234 DelegationActionType::ScopesModified, 235 Some(serde_json::json!({ 236 + "new_scopes": input.granted_scopes.as_str() 237 })), 238 None, 239 None, ··· 298 .map(|a| DelegatedAccountInfo { 299 did: a.did, 300 handle: a.handle, 301 + granted_scopes: a.granted_scopes.into_string(), 302 granted_at: a.granted_at, 303 }) 304 .collect(), ··· 415 pub struct CreateDelegatedAccountInput { 416 pub handle: String, 417 pub email: Option<String>, 418 + pub controller_scopes: ValidatedDelegationScope, 419 pub invite_code: Option<String>, 420 } 421 ··· 432 auth: Auth<Active>, 433 Json(input): Json<CreateDelegatedAccountInput>, 434 ) -> Result<Response, ApiError> { 435 + let can_control = match verify_can_control_accounts(&state, &auth).await { 436 Ok(proof) => proof, 437 Err(response) => return Ok(response), 438 }; ··· 471 return Ok(ApiError::InvalidEmail.into_response()); 472 } 473 474 + let validated_invite_code = if let Some(ref code) = input.invite_code { 475 + match state.infra_repo.validate_invite_code(code).await { 476 + Ok(validated) => Some(validated), 477 + Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()), 478 } 479 } else { 480 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 483 if invite_required { 484 return Ok(ApiError::InviteCodeRequired.into_response()); 485 } 486 + None 487 + }; 488 489 use k256::ecdsa::SigningKey; 490 use rand::rngs::OsRng; ··· 535 536 let did = Did::new_unchecked(&genesis_result.did); 537 let handle = Handle::new_unchecked(&handle); 538 + info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account"); 539 540 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 541 Ok(bytes) => bytes, ··· 575 handle: handle.clone(), 576 email: email.clone(), 577 did: did.clone(), 578 + controller_did: can_control.did().clone(), 579 + controller_scopes: input.controller_scopes.as_str().to_string(), 580 encrypted_key_bytes, 581 encryption_version: crate::config::ENCRYPTION_VERSION, 582 commit_cid: commit_cid.to_string(), ··· 585 invite_code: input.invite_code.clone(), 586 }; 587 588 + let user_id = match state 589 .user_repo 590 .create_delegated_account(&create_input) 591 .await ··· 603 } 604 }; 605 606 + if let Some(validated) = validated_invite_code { 607 + if let Err(e) = state.infra_repo.record_invite_code_use(&validated, user_id).await { 608 + warn!("Failed to record invite code use for {}: {:?}", did, e); 609 + } 610 + } 611 + 612 if let Err(e) = 613 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 614 { 615 warn!("Failed to sequence identity event for {}: {}", did, e); 616 } 617 + if let Err(e) = crate::api::repo::record::sequence_account_event( 618 + &state, 619 + &did, 620 + tranquil_db_traits::AccountStatus::Active, 621 + ) 622 + .await 623 { 624 warn!("Failed to sequence account event for {}: {}", did, e); 625 } ··· 651 DelegationActionType::GrantCreated, 652 Some(json!({ 653 "account_created": true, 654 + "granted_scopes": input.controller_scopes.as_str() 655 })), 656 None, 657 None,
crates/tranquil-pds/src/api/error.rs

This file has not been changed.

+11 -11
crates/tranquil-pds/src/api/identity/account.rs
··· 298 d.clone() 299 } else if d.starts_with("did:web:") { 300 if !is_did_web_byod 301 - && let Err(e) = verify_did_web( 302 - d, 303 - hostname, 304 - &input.handle, 305 - input.signing_key.as_deref(), 306 - ) 307 - .await 308 { 309 return ApiError::InvalidDid(e).into_response(); 310 } ··· 444 refresh_jti: refresh_meta.jti.clone(), 445 access_expires_at: access_meta.expires_at, 446 refresh_expires_at: refresh_meta.expires_at, 447 - legacy_login: false, 448 mfa_verified: false, 449 scope: None, 450 controller_did: None, ··· 687 { 688 warn!("Failed to sequence identity event for {}: {}", did, e); 689 } 690 - if let Err(e) = 691 - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await 692 { 693 warn!("Failed to sequence account event for {}: {}", did, e); 694 } ··· 796 refresh_jti: refresh_meta.jti.clone(), 797 access_expires_at: access_meta.expires_at, 798 refresh_expires_at: refresh_meta.expires_at, 799 - legacy_login: false, 800 mfa_verified: false, 801 scope: None, 802 controller_did: None,
··· 298 d.clone() 299 } else if d.starts_with("did:web:") { 300 if !is_did_web_byod 301 + && let Err(e) = 302 + verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()) 303 + .await 304 { 305 return ApiError::InvalidDid(e).into_response(); 306 } ··· 440 refresh_jti: refresh_meta.jti.clone(), 441 access_expires_at: access_meta.expires_at, 442 refresh_expires_at: refresh_meta.expires_at, 443 + login_type: tranquil_db_traits::LoginType::Modern, 444 mfa_verified: false, 445 scope: None, 446 controller_did: None, ··· 683 { 684 warn!("Failed to sequence identity event for {}: {}", did, e); 685 } 686 + if let Err(e) = crate::api::repo::record::sequence_account_event( 687 + &state, 688 + &did_typed, 689 + tranquil_db_traits::AccountStatus::Active, 690 + ) 691 + .await 692 { 693 warn!("Failed to sequence account event for {}: {}", did, e); 694 } ··· 796 refresh_jti: refresh_meta.jti.clone(), 797 access_expires_at: access_meta.expires_at, 798 refresh_expires_at: refresh_meta.expires_at, 799 + login_type: tranquil_db_traits::LoginType::Modern, 800 mfa_verified: false, 801 scope: None, 802 controller_did: None,
+3 -1
crates/tranquil-pds/src/api/identity/did.rs
··· 1 use crate::api::{ApiError, DidResponse, EmptyResponse}; 2 use crate::auth::{Auth, NotTakendown}; 3 use crate::plc::signing_key_to_did_key; 4 - use crate::rate_limit::{HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message}; 5 use crate::state::AppState; 6 use crate::types::Handle; 7 use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port};
··· 1 use crate::api::{ApiError, DidResponse, EmptyResponse}; 2 use crate::auth::{Auth, NotTakendown}; 3 use crate::plc::signing_key_to_did_key; 4 + use crate::rate_limit::{ 5 + HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message, 6 + }; 7 use crate::state::AppState; 8 use crate::types::Handle; 9 use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port};
crates/tranquil-pds/src/api/identity/plc/request.rs

This file has not been changed.

+1 -1
crates/tranquil-pds/src/api/identity/plc/sign.rs
··· 1 - use crate::api::error::DbResultExt; 2 use crate::api::ApiError; 3 use crate::auth::{Auth, Permissive}; 4 use crate::circuit_breaker::with_circuit_breaker; 5 use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
··· 1 use crate::api::ApiError; 2 + use crate::api::error::DbResultExt; 3 use crate::auth::{Auth, Permissive}; 4 use crate::circuit_breaker::with_circuit_breaker; 5 use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
crates/tranquil-pds/src/api/identity/plc/submit.rs

This file has not been changed.

+30 -25
crates/tranquil-pds/src/api/notification_prefs.rs
··· 10 use serde::{Deserialize, Serialize}; 11 use serde_json::json; 12 use tracing::info; 13 14 #[derive(Serialize)] 15 #[serde(rename_all = "camelCase")] 16 pub struct NotificationPrefsResponse { 17 - pub preferred_channel: String, 18 pub email: String, 19 pub discord_id: Option<String>, 20 pub discord_verified: bool, ··· 51 #[serde(rename_all = "camelCase")] 52 pub struct NotificationHistoryEntry { 53 pub created_at: String, 54 - pub channel: String, 55 - pub comms_type: String, 56 - pub status: String, 57 pub subject: Option<String>, 58 pub body: String, 59 } ··· 82 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 83 84 let sensitive_types = [ 85 - "email_verification", 86 - "password_reset", 87 - "email_update", 88 - "two_factor_code", 89 - "passkey_recovery", 90 - "migration_verification", 91 - "plc_operation", 92 - "channel_verification", 93 - "signup_verification", 94 ]; 95 96 let notifications = rows 97 .iter() 98 .map(|row| { 99 - let body = if sensitive_types.contains(&row.comms_type.as_str()) { 100 "[Code redacted for security]".to_string() 101 } else { 102 row.body.clone() 103 }; 104 NotificationHistoryEntry { 105 created_at: row.created_at.to_rfc3339(), 106 - channel: row.channel.clone(), 107 - comms_type: row.comms_type.clone(), 108 - status: row.status.clone(), 109 subject: row.subject.clone(), 110 body, 111 } ··· 201 202 let mut verification_required: Vec<String> = Vec::new(); 203 204 - if let Some(ref channel) = input.preferred_channel { 205 - let valid_channels = ["email", "discord", "telegram", "signal"]; 206 - if !valid_channels.contains(&channel.as_str()) { 207 - return Err(ApiError::InvalidRequest( 208 - "Invalid channel. Must be one of: email, discord, telegram, signal".into(), 209 - )); 210 - } 211 state 212 .user_repo 213 .update_preferred_comms_channel(&auth.did, channel) 214 .await 215 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 216 - info!(did = %auth.did, channel = %channel, "Updated preferred notification channel"); 217 } 218 219 if let Some(ref new_email) = input.email {
··· 10 use serde::{Deserialize, Serialize}; 11 use serde_json::json; 12 use tracing::info; 13 + use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType}; 14 15 #[derive(Serialize)] 16 #[serde(rename_all = "camelCase")] 17 pub struct NotificationPrefsResponse { 18 + pub preferred_channel: CommsChannel, 19 pub email: String, 20 pub discord_id: Option<String>, 21 pub discord_verified: bool, ··· 52 #[serde(rename_all = "camelCase")] 53 pub struct NotificationHistoryEntry { 54 pub created_at: String, 55 + pub channel: CommsChannel, 56 + pub comms_type: CommsType, 57 + pub status: CommsStatus, 58 pub subject: Option<String>, 59 pub body: String, 60 } ··· 83 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 84 85 let sensitive_types = [ 86 + CommsType::EmailVerification, 87 + CommsType::PasswordReset, 88 + CommsType::EmailUpdate, 89 + CommsType::TwoFactorCode, 90 + CommsType::PasskeyRecovery, 91 + CommsType::MigrationVerification, 92 + CommsType::PlcOperation, 93 + CommsType::ChannelVerification, 94 ]; 95 96 let notifications = rows 97 .iter() 98 .map(|row| { 99 + let body = if sensitive_types.contains(&row.comms_type) { 100 "[Code redacted for security]".to_string() 101 } else { 102 row.body.clone() 103 }; 104 NotificationHistoryEntry { 105 created_at: row.created_at.to_rfc3339(), 106 + channel: row.channel, 107 + comms_type: row.comms_type, 108 + status: row.status, 109 subject: row.subject.clone(), 110 body, 111 } ··· 201 202 let mut verification_required: Vec<String> = Vec::new(); 203 204 + if let Some(ref channel_str) = input.preferred_channel { 205 + let channel = match channel_str.as_str() { 206 + "email" => CommsChannel::Email, 207 + "discord" => CommsChannel::Discord, 208 + "telegram" => CommsChannel::Telegram, 209 + "signal" => CommsChannel::Signal, 210 + _ => { 211 + return Err(ApiError::InvalidRequest( 212 + "Invalid channel. Must be one of: email, discord, telegram, signal".into(), 213 + )); 214 + } 215 + }; 216 state 217 .user_repo 218 .update_preferred_comms_channel(&auth.did, channel) 219 .await 220 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 221 + info!(did = %auth.did, channel = ?channel, "Updated preferred notification channel"); 222 } 223 224 if let Some(ref new_email) = input.email {
crates/tranquil-pds/src/api/proxy.rs

This file has not been changed.

+5 -2
crates/tranquil-pds/src/api/repo/blob.rs
··· 58 } 59 let mime_type_for_check = 60 get_header_str(&headers, "content-type").unwrap_or("application/octet-stream"); 61 - let _scope_proof = match user.verify_blob_upload(mime_type_for_check) { 62 Ok(proof) => proof, 63 Err(e) => return Ok(e.into_response()), 64 }; 65 - (user.did.clone(), user.controller_did.clone()) 66 } 67 }; 68
··· 58 } 59 let mime_type_for_check = 60 get_header_str(&headers, "content-type").unwrap_or("application/octet-stream"); 61 + let scope_proof = match user.verify_blob_upload(mime_type_for_check) { 62 Ok(proof) => proof, 63 Err(e) => return Ok(e.into_response()), 64 }; 65 + ( 66 + scope_proof.principal_did().into_did(), 67 + scope_proof.controller_did().map(|c| c.into_did()), 68 + ) 69 } 70 }; 71
+1 -1
crates/tranquil-pds/src/api/repo/import.rs
··· 365 ) -> Result<(), tranquil_db::DbError> { 366 let data = tranquil_db::CommitEventData { 367 did: did.clone(), 368 - event_type: "commit".to_string(), 369 commit_cid: Some(CidLink::new_unchecked(commit_cid)), 370 prev_cid: None, 371 ops: Some(serde_json::json!([])),
··· 365 ) -> Result<(), tranquil_db::DbError> { 366 let data = tranquil_db::CommitEventData { 367 did: did.clone(), 368 + event_type: tranquil_db::RepoEventType::Commit, 369 commit_cid: Some(CidLink::new_unchecked(commit_cid)), 370 prev_cid: None, 371 ops: Some(serde_json::json!([])),
crates/tranquil-pds/src/api/repo/meta.rs

This file has not been changed.

+71 -113
crates/tranquil-pds/src/api/repo/record/batch.rs
··· 1 use super::validation::validate_record_with_status; 2 use crate::api::error::ApiError; 3 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 4 - use crate::auth::{Active, Auth, VerifyScope}; 5 use crate::delegation::DelegationActionType; 6 use crate::repo::tracking::TrackingBlockStore; 7 use crate::state::AppState; ··· 34 write: &WriteOp, 35 acc: WriteAccumulator, 36 did: &Did, 37 - validate: Option<bool>, 38 tracking_store: &TrackingBlockStore, 39 ) -> Result<WriteAccumulator, Response> { 40 let WriteAccumulator { ··· 51 rkey, 52 value, 53 } => { 54 - let validation_status = match validate { 55 - Some(false) => None, 56 - _ => { 57 - let require_lexicon = validate == Some(true); 58 - match validate_record_with_status( 59 - value, 60 - collection, 61 - rkey.as_ref(), 62 - require_lexicon, 63 - ) { 64 - Ok(status) => Some(status), 65 - Err(err_response) => return Err(*err_response), 66 - } 67 } 68 }; 69 all_blob_cids.extend(extract_blob_cids(value)); ··· 104 rkey, 105 value, 106 } => { 107 - let validation_status = match validate { 108 - Some(false) => None, 109 - _ => { 110 - let require_lexicon = validate == Some(true); 111 - match validate_record_with_status( 112 - value, 113 - collection, 114 - Some(rkey), 115 - require_lexicon, 116 - ) { 117 - Ok(status) => Some(status), 118 - Err(err_response) => return Err(*err_response), 119 - } 120 } 121 }; 122 all_blob_cids.extend(extract_blob_cids(value)); ··· 181 writes: &[WriteOp], 182 initial_mst: Mst<TrackingBlockStore>, 183 did: &Did, 184 - validate: Option<bool>, 185 tracking_store: &TrackingBlockStore, 186 ) -> Result<WriteAccumulator, Response> { 187 use futures::stream::{self, TryStreamExt}; ··· 222 #[serde(rename_all = "camelCase")] 223 pub struct ApplyWritesInput { 224 pub repo: AtIdentifier, 225 - pub validate: Option<bool>, 226 pub writes: Vec<WriteOp>, 227 pub swap_commit: Option<String>, 228 } ··· 270 input.repo, 271 input.writes.len() 272 ); 273 - let did = auth.did.clone(); 274 - let controller_did = auth.controller_did.clone(); 275 - if input.repo.as_str() != did { 276 - return Err(ApiError::InvalidRepo( 277 - "Repo does not match authenticated user".into(), 278 - )); 279 - } 280 - if state 281 - .user_repo 282 - .is_account_migrated(&did) 283 - .await 284 - .unwrap_or(false) 285 - { 286 - return Err(ApiError::AccountMigrated); 287 - } 288 - let is_verified = state 289 - .user_repo 290 - .has_verified_comms_channel(&did) 291 - .await 292 - .unwrap_or(false); 293 - let is_delegated = state 294 - .delegation_repo 295 - .is_delegated_account(&did) 296 - .await 297 - .unwrap_or(false); 298 - if !is_verified && !is_delegated { 299 - return Err(ApiError::AccountNotVerified); 300 - } 301 if input.writes.is_empty() { 302 return Err(ApiError::InvalidRequest("writes array is empty".into())); 303 } ··· 308 ))); 309 } 310 311 - { 312 - use std::collections::HashSet; 313 - let create_collections: HashSet<&Nsid> = input 314 - .writes 315 - .iter() 316 - .filter_map(|w| { 317 - if let WriteOp::Create { collection, .. } = w { 318 - Some(collection) 319 - } else { 320 - None 321 - } 322 - }) 323 - .collect(); 324 - let update_collections: HashSet<&Nsid> = input 325 - .writes 326 - .iter() 327 - .filter_map(|w| { 328 - if let WriteOp::Update { collection, .. } = w { 329 - Some(collection) 330 - } else { 331 - None 332 - } 333 - }) 334 - .collect(); 335 - let delete_collections: HashSet<&Nsid> = input 336 - .writes 337 - .iter() 338 - .filter_map(|w| { 339 - if let WriteOp::Delete { collection, .. } = w { 340 - Some(collection) 341 - } else { 342 - None 343 - } 344 - }) 345 - .collect(); 346 347 - for collection in &create_collections { 348 - if let Err(e) = auth.verify_repo_create(collection) { 349 - return Ok(e.into_response()); 350 - } 351 - } 352 - for collection in &update_collections { 353 - if let Err(e) = auth.verify_repo_update(collection) { 354 - return Ok(e.into_response()); 355 - } 356 - } 357 - for collection in &delete_collections { 358 - if let Err(e) = auth.verify_repo_delete(collection) { 359 - return Ok(e.into_response()); 360 - } 361 - } 362 } 363 364 let user_id: uuid::Uuid = state 365 366 ··· 375 .ok() 376 .flatten() 377 .ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?; 378 - let current_root_cid = Cid::from_str(&root_cid_str) 379 .map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?; 380 if let Some(swap_commit) = &input.swap_commit 381 - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) 382 { 383 return Err(ApiError::InvalidSwap(Some("Repo has been modified".into()))); 384 } 385 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 386 let commit_bytes = tracking_store 387 - .get(&current_root_cid) 388 .await 389 .ok() 390 .flatten() ··· 452 } => Some(*cid), 453 _ => None, 454 }); 455 - let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid) 456 .chain( 457 old_mst_blocks 458 .keys() ··· 468 CommitParams { 469 did: &did, 470 user_id, 471 - current_root_cid: Some(current_root_cid), 472 prev_data_cid: Some(commit.data), 473 new_mst_root, 474 ops,
··· 1 use super::validation::validate_record_with_status; 2 + use super::validation_mode::{ValidationMode, deserialize_validation_mode}; 3 use crate::api::error::ApiError; 4 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 5 + use crate::auth::{ 6 + Active, Auth, WriteOpKind, require_not_migrated, require_verified_or_delegated, 7 + verify_batch_write_scopes, 8 + }; 9 + use crate::cid_types::CommitCid; 10 use crate::delegation::DelegationActionType; 11 use crate::repo::tracking::TrackingBlockStore; 12 use crate::state::AppState; ··· 39 write: &WriteOp, 40 acc: WriteAccumulator, 41 did: &Did, 42 + validate: ValidationMode, 43 tracking_store: &TrackingBlockStore, 44 ) -> Result<WriteAccumulator, Response> { 45 let WriteAccumulator { ··· 56 rkey, 57 value, 58 } => { 59 + let validation_status = if validate.should_skip() { 60 + None 61 + } else { 62 + match validate_record_with_status( 63 + value, 64 + collection, 65 + rkey.as_ref(), 66 + validate.requires_lexicon(), 67 + ) { 68 + Ok(status) => Some(status), 69 + Err(err_response) => return Err(*err_response), 70 } 71 }; 72 all_blob_cids.extend(extract_blob_cids(value)); ··· 107 rkey, 108 value, 109 } => { 110 + let validation_status = if validate.should_skip() { 111 + None 112 + } else { 113 + match validate_record_with_status( 114 + value, 115 + collection, 116 + Some(rkey), 117 + validate.requires_lexicon(), 118 + ) { 119 + Ok(status) => Some(status), 120 + Err(err_response) => return Err(*err_response), 121 } 122 }; 123 all_blob_cids.extend(extract_blob_cids(value)); ··· 182 writes: &[WriteOp], 183 initial_mst: Mst<TrackingBlockStore>, 184 did: &Did, 185 + validate: ValidationMode, 186 tracking_store: &TrackingBlockStore, 187 ) -> Result<WriteAccumulator, Response> { 188 use futures::stream::{self, TryStreamExt}; ··· 223 #[serde(rename_all = "camelCase")] 224 pub struct ApplyWritesInput { 225 pub repo: AtIdentifier, 226 + #[serde(default, deserialize_with = "deserialize_validation_mode")] 227 + pub validate: ValidationMode, 228 pub writes: Vec<WriteOp>, 229 pub swap_commit: Option<String>, 230 } ··· 272 input.repo, 273 input.writes.len() 274 ); 275 + 276 if input.writes.is_empty() { 277 return Err(ApiError::InvalidRequest("writes array is empty".into())); 278 } ··· 283 ))); 284 } 285 286 + let batch_proof = match verify_batch_write_scopes( 287 + &auth, 288 + &auth, 289 + &input.writes, 290 + |w| match w { 291 + WriteOp::Create { collection, .. } => collection.as_str(), 292 + WriteOp::Update { collection, .. } => collection.as_str(), 293 + WriteOp::Delete { collection, .. } => collection.as_str(), 294 + }, 295 + |w| match w { 296 + WriteOp::Create { .. } => WriteOpKind::Create, 297 + WriteOp::Update { .. } => WriteOpKind::Update, 298 + WriteOp::Delete { .. } => WriteOpKind::Delete, 299 + }, 300 + ) { 301 + Ok(proof) => proof, 302 + Err(e) => return Ok(e.into_response()), 303 + }; 304 305 + let principal_did = batch_proof.principal_did(); 306 + let controller_did = batch_proof.controller_did().map(|c| c.into_did()); 307 + 308 + if input.repo.as_str() != principal_did.as_str() { 309 + return Err(ApiError::InvalidRepo( 310 + "Repo does not match authenticated user".into(), 311 + )); 312 } 313 314 + let did = principal_did.into_did(); 315 + if let Err(e) = require_not_migrated(&state, &did).await { 316 + return Ok(e); 317 + } 318 + if let Err(e) = require_verified_or_delegated(&state, batch_proof.user()).await { 319 + return Ok(e); 320 + } 321 + 322 let user_id: uuid::Uuid = state 323 324 ··· 333 .ok() 334 .flatten() 335 .ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?; 336 + let current_root_cid = CommitCid::from_str(&root_cid_str) 337 .map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?; 338 if let Some(swap_commit) = &input.swap_commit 339 + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) 340 { 341 return Err(ApiError::InvalidSwap(Some("Repo has been modified".into()))); 342 } 343 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 344 let commit_bytes = tracking_store 345 + .get(current_root_cid.as_cid()) 346 .await 347 .ok() 348 .flatten() ··· 410 } => Some(*cid), 411 _ => None, 412 }); 413 + let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid()) 414 .chain( 415 old_mst_blocks 416 .keys() ··· 426 CommitParams { 427 did: &did, 428 user_id, 429 + current_root_cid: Some(current_root_cid.into_cid()), 430 prev_data_cid: Some(commit.data), 431 new_mst_root, 432 ops,
+7 -6
crates/tranquil-pds/src/api/repo/record/delete.rs
··· 2 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 3 use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; 4 use crate::auth::{Active, Auth, VerifyScope}; 5 use crate::delegation::DelegationActionType; 6 use crate::repo::tracking::TrackingBlockStore; 7 use crate::state::AppState; ··· 43 auth: Auth<Active>, 44 Json(input): Json<DeleteRecordInput>, 45 ) -> Result<Response, crate::api::error::ApiError> { 46 - let _scope_proof = match auth.verify_repo_delete(&input.collection) { 47 Ok(proof) => proof, 48 Err(e) => return Ok(e.into_response()), 49 }; 50 51 - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { 52 Ok(res) => res, 53 Err(err_res) => return Ok(err_res), 54 }; ··· 59 let controller_did = repo_auth.controller_did; 60 61 if let Some(swap_commit) = &input.swap_commit 62 - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) 63 { 64 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 65 } 66 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 67 - let commit_bytes = match tracking_store.get(&current_root_cid).await { 68 Ok(Some(b)) => b, 69 _ => { 70 return Ok( ··· 155 .into_iter() 156 .collect(); 157 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 158 - let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid) 159 .chain( 160 old_mst_blocks 161 .keys() ··· 169 CommitParams { 170 did: &did, 171 user_id, 172 - current_root_cid: Some(current_root_cid), 173 prev_data_cid: Some(commit.data), 174 new_mst_root, 175 ops: vec![op],
··· 2 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 3 use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; 4 use crate::auth::{Active, Auth, VerifyScope}; 5 + use crate::cid_types::CommitCid; 6 use crate::delegation::DelegationActionType; 7 use crate::repo::tracking::TrackingBlockStore; 8 use crate::state::AppState; ··· 44 auth: Auth<Active>, 45 Json(input): Json<DeleteRecordInput>, 46 ) -> Result<Response, crate::api::error::ApiError> { 47 + let scope_proof = match auth.verify_repo_delete(&input.collection) { 48 Ok(proof) => proof, 49 Err(e) => return Ok(e.into_response()), 50 }; 51 52 + let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await { 53 Ok(res) => res, 54 Err(err_res) => return Ok(err_res), 55 }; ··· 60 let controller_did = repo_auth.controller_did; 61 62 if let Some(swap_commit) = &input.swap_commit 63 + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) 64 { 65 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 66 } 67 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 68 + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { 69 Ok(Some(b)) => b, 70 _ => { 71 return Ok( ··· 156 .into_iter() 157 .collect(); 158 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 159 + let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid()) 160 .chain( 161 old_mst_blocks 162 .keys() ··· 170 CommitParams { 171 did: &did, 172 user_id, 173 + current_root_cid: Some(current_root_cid.into_cid()), 174 prev_data_cid: Some(commit.data), 175 new_mst_root, 176 ops: vec![op],
+4 -3
crates/tranquil-pds/src/api/repo/record/read.rs
··· 1 use crate::api::error::ApiError; 2 use crate::state::AppState; 3 use crate::types::{AtIdentifier, Nsid, Rkey}; ··· 144 pub rkey_start: Option<Rkey>, 145 #[serde(rename = "rkeyEnd")] 146 pub rkey_end: Option<Rkey>, 147 - pub reverse: Option<bool>, 148 } 149 #[derive(Serialize)] 150 pub struct ListRecordsOutput { ··· 193 } 194 }; 195 let limit = input.limit.unwrap_or(50).clamp(1, 100); 196 - let reverse = input.reverse.unwrap_or(false); 197 let limit_i64 = limit as i64; 198 let cursor_rkey = input 199 .cursor ··· 206 &input.collection, 207 cursor_rkey.as_ref(), 208 limit_i64, 209 - reverse, 210 input.rkey_start.as_ref(), 211 input.rkey_end.as_ref(), 212 )
··· 1 + use super::pagination::{PaginationDirection, deserialize_pagination_direction}; 2 use crate::api::error::ApiError; 3 use crate::state::AppState; 4 use crate::types::{AtIdentifier, Nsid, Rkey}; ··· 145 pub rkey_start: Option<Rkey>, 146 #[serde(rename = "rkeyEnd")] 147 pub rkey_end: Option<Rkey>, 148 + #[serde(default, deserialize_with = "deserialize_pagination_direction")] 149 + pub reverse: PaginationDirection, 150 } 151 #[derive(Serialize)] 152 pub struct ListRecordsOutput { ··· 195 } 196 }; 197 let limit = input.limit.unwrap_or(50).clamp(1, 100); 198 let limit_i64 = limit as i64; 199 let cursor_rkey = input 200 .cursor ··· 207 &input.collection, 208 cursor_rkey.as_ref(), 209 limit_i64, 210 + input.reverse.is_reverse(), 211 input.rkey_start.as_ref(), 212 input.rkey_end.as_ref(), 213 )
+43 -57
crates/tranquil-pds/src/api/repo/record/write.rs
··· 1 use super::validation::validate_record_with_status; 2 use crate::api::error::ApiError; 3 use crate::api::repo::record::utils::{ 4 CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids, 5 }; 6 - use crate::auth::{Active, Auth, VerifyScope}; 7 use crate::delegation::DelegationActionType; 8 use crate::repo::tracking::TrackingBlockStore; 9 use crate::state::AppState; ··· 26 pub struct RepoWriteAuth { 27 pub did: Did, 28 pub user_id: Uuid, 29 - pub current_root_cid: Cid, 30 pub is_oauth: bool, 31 pub scope: Option<String>, 32 pub controller_did: Option<Did>, 33 } 34 35 - pub async fn prepare_repo_write( 36 state: &AppState, 37 - auth_user: &crate::auth::AuthenticatedUser, 38 repo: &AtIdentifier, 39 ) -> Result<RepoWriteAuth, Response> { 40 - if repo.as_str() != auth_user.did.as_str() { 41 return Err( 42 ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(), 43 ); 44 } 45 - if state 46 - .user_repo 47 - .is_account_migrated(&auth_user.did) 48 - .await 49 - .unwrap_or(false) 50 - { 51 - return Err(ApiError::AccountMigrated.into_response()); 52 - } 53 - let is_verified = state 54 - .user_repo 55 - .has_verified_comms_channel(&auth_user.did) 56 - .await 57 - .unwrap_or(false); 58 - let is_delegated = state 59 - .delegation_repo 60 - .is_delegated_account(&auth_user.did) 61 - .await 62 - .unwrap_or(false); 63 - if !is_verified && !is_delegated { 64 - return Err(ApiError::AccountNotVerified.into_response()); 65 - } 66 let user_id = state 67 .user_repo 68 - .get_id_by_did(&auth_user.did) 69 .await 70 .map_err(|e| { 71 error!("DB error fetching user: {}", e); ··· 83 .ok_or_else(|| { 84 ApiError::InternalError(Some("Repo root not found".into())).into_response() 85 })?; 86 - let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| { 87 ApiError::InternalError(Some("Invalid repo root CID".into())).into_response() 88 })?; 89 Ok(RepoWriteAuth { 90 - did: auth_user.did.clone(), 91 user_id, 92 current_root_cid, 93 - is_oauth: auth_user.is_oauth(), 94 - scope: auth_user.scope.clone(), 95 - controller_did: auth_user.controller_did.clone(), 96 }) 97 } 98 #[derive(Deserialize)] ··· 101 pub repo: AtIdentifier, 102 pub collection: Nsid, 103 pub rkey: Option<Rkey>, 104 - pub validate: Option<bool>, 105 pub record: serde_json::Value, 106 #[serde(rename = "swapCommit")] 107 pub swap_commit: Option<String>, ··· 127 auth: Auth<Active>, 128 Json(input): Json<CreateRecordInput>, 129 ) -> Result<Response, crate::api::error::ApiError> { 130 - let _scope_proof = match auth.verify_repo_create(&input.collection) { 131 Ok(proof) => proof, 132 Err(e) => return Ok(e.into_response()), 133 }; 134 135 - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { 136 Ok(res) => res, 137 Err(err_res) => return Ok(err_res), 138 }; ··· 143 let controller_did = repo_auth.controller_did; 144 145 if let Some(swap_commit) = &input.swap_commit 146 - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) 147 { 148 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 149 } 150 151 - let validation_status = if input.validate == Some(false) { 152 None 153 } else { 154 - let require_lexicon = input.validate == Some(true); 155 match validate_record_with_status( 156 &input.record, 157 &input.collection, 158 input.rkey.as_ref(), 159 - require_lexicon, 160 ) { 161 Ok(status) => Some(status), 162 Err(err_response) => return Ok(*err_response), ··· 165 let rkey = input.rkey.unwrap_or_else(Rkey::generate); 166 167 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 168 - let commit_bytes = match tracking_store.get(&current_root_cid).await { 169 Ok(Some(b)) => b, 170 _ => { 171 return Ok( ··· 188 let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new(); 189 let mut all_old_mst_blocks = std::collections::BTreeMap::new(); 190 191 - if input.validate != Some(false) { 192 let record_uri = AtUri::from_parts(&did, &input.collection, &rkey); 193 let backlinks = extract_backlinks(&record_uri, &input.record); 194 ··· 319 .collect(); 320 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 321 let blob_cids = extract_blob_cids(&input.record); 322 - let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid) 323 .chain( 324 all_old_mst_blocks 325 .keys() ··· 333 CommitParams { 334 did: &did, 335 user_id, 336 - current_root_cid: Some(current_root_cid), 337 prev_data_cid: Some(initial_mst_root), 338 new_mst_root, 339 ops, ··· 408 pub repo: AtIdentifier, 409 pub collection: Nsid, 410 pub rkey: Rkey, 411 - pub validate: Option<bool>, 412 pub record: serde_json::Value, 413 #[serde(rename = "swapCommit")] 414 pub swap_commit: Option<String>, ··· 430 auth: Auth<Active>, 431 Json(input): Json<PutRecordInput>, 432 ) -> Result<Response, crate::api::error::ApiError> { 433 - let _create_proof = match auth.verify_repo_create(&input.collection) { 434 Ok(proof) => proof, 435 Err(e) => return Ok(e.into_response()), 436 }; 437 - let _update_proof = match auth.verify_repo_update(&input.collection) { 438 - Ok(proof) => proof, 439 - Err(e) => return Ok(e.into_response()), 440 - }; 441 442 - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { 443 Ok(res) => res, 444 Err(err_res) => return Ok(err_res), 445 }; ··· 450 let controller_did = repo_auth.controller_did; 451 452 if let Some(swap_commit) = &input.swap_commit 453 - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) 454 { 455 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 456 } 457 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 458 - let commit_bytes = match tracking_store.get(&current_root_cid).await { 459 Ok(Some(b)) => b, 460 _ => { 461 return Ok( ··· 473 }; 474 let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); 475 let key = format!("{}/{}", input.collection, input.rkey); 476 - let validation_status = if input.validate == Some(false) { 477 None 478 } else { 479 - let require_lexicon = input.validate == Some(true); 480 match validate_record_with_status( 481 &input.record, 482 &input.collection, 483 Some(&input.rkey), 484 - require_lexicon, 485 ) { 486 Ok(status) => Some(status), 487 Err(err_response) => return Ok(*err_response), ··· 598 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 599 let is_update = existing_cid.is_some(); 600 let blob_cids = extract_blob_cids(&input.record); 601 - let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid) 602 .chain( 603 old_mst_blocks 604 .keys() ··· 612 CommitParams { 613 did: &did, 614 user_id, 615 - current_root_cid: Some(current_root_cid), 616 prev_data_cid: Some(commit.data), 617 new_mst_root, 618 ops: vec![op],
··· 1 use super::validation::validate_record_with_status; 2 + use super::validation_mode::{ValidationMode, deserialize_validation_mode}; 3 use crate::api::error::ApiError; 4 use crate::api::repo::record::utils::{ 5 CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids, 6 }; 7 + use crate::auth::{ 8 + Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated, 9 + require_verified_or_delegated, 10 + }; 11 + use crate::cid_types::CommitCid; 12 use crate::delegation::DelegationActionType; 13 use crate::repo::tracking::TrackingBlockStore; 14 use crate::state::AppState; ··· 31 pub struct RepoWriteAuth { 32 pub did: Did, 33 pub user_id: Uuid, 34 + pub current_root_cid: CommitCid, 35 pub is_oauth: bool, 36 pub scope: Option<String>, 37 pub controller_did: Option<Did>, 38 } 39 40 + pub async fn prepare_repo_write<A: RepoScopeAction>( 41 state: &AppState, 42 + scope_proof: &ScopeVerified<'_, A>, 43 repo: &AtIdentifier, 44 ) -> Result<RepoWriteAuth, Response> { 45 + let user = scope_proof.user(); 46 + let principal_did = scope_proof.principal_did(); 47 + if repo.as_str() != principal_did.as_str() { 48 return Err( 49 ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(), 50 ); 51 } 52 + 53 + require_not_migrated(state, principal_did.as_did()).await?; 54 + let _account_verified = require_verified_or_delegated(state, user).await?; 55 + 56 let user_id = state 57 .user_repo 58 + .get_id_by_did(principal_did.as_did()) 59 .await 60 .map_err(|e| { 61 error!("DB error fetching user: {}", e); ··· 73 .ok_or_else(|| { 74 ApiError::InternalError(Some("Repo root not found".into())).into_response() 75 })?; 76 + let current_root_cid = CommitCid::from_str(&root_cid_str).map_err(|_| { 77 ApiError::InternalError(Some("Invalid repo root CID".into())).into_response() 78 })?; 79 Ok(RepoWriteAuth { 80 + did: principal_did.into_did(), 81 user_id, 82 current_root_cid, 83 + is_oauth: user.is_oauth(), 84 + scope: user.scope.clone(), 85 + controller_did: scope_proof.controller_did().map(|c| c.into_did()), 86 }) 87 } 88 #[derive(Deserialize)] ··· 91 pub repo: AtIdentifier, 92 pub collection: Nsid, 93 pub rkey: Option<Rkey>, 94 + #[serde(default, deserialize_with = "deserialize_validation_mode")] 95 + pub validate: ValidationMode, 96 pub record: serde_json::Value, 97 #[serde(rename = "swapCommit")] 98 pub swap_commit: Option<String>, ··· 118 auth: Auth<Active>, 119 Json(input): Json<CreateRecordInput>, 120 ) -> Result<Response, crate::api::error::ApiError> { 121 + let scope_proof = match auth.verify_repo_create(&input.collection) { 122 Ok(proof) => proof, 123 Err(e) => return Ok(e.into_response()), 124 }; 125 126 + let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await { 127 Ok(res) => res, 128 Err(err_res) => return Ok(err_res), 129 }; ··· 134 let controller_did = repo_auth.controller_did; 135 136 if let Some(swap_commit) = &input.swap_commit 137 + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) 138 { 139 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 140 } 141 142 + let validation_status = if input.validate.should_skip() { 143 None 144 } else { 145 match validate_record_with_status( 146 &input.record, 147 &input.collection, 148 input.rkey.as_ref(), 149 + input.validate.requires_lexicon(), 150 ) { 151 Ok(status) => Some(status), 152 Err(err_response) => return Ok(*err_response), ··· 155 let rkey = input.rkey.unwrap_or_else(Rkey::generate); 156 157 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 158 + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { 159 Ok(Some(b)) => b, 160 _ => { 161 return Ok( ··· 178 let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new(); 179 let mut all_old_mst_blocks = std::collections::BTreeMap::new(); 180 181 + if !input.validate.should_skip() { 182 let record_uri = AtUri::from_parts(&did, &input.collection, &rkey); 183 let backlinks = extract_backlinks(&record_uri, &input.record); 184 ··· 309 .collect(); 310 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 311 let blob_cids = extract_blob_cids(&input.record); 312 + let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid()) 313 .chain( 314 all_old_mst_blocks 315 .keys() ··· 323 CommitParams { 324 did: &did, 325 user_id, 326 + current_root_cid: Some(current_root_cid.into_cid()), 327 prev_data_cid: Some(initial_mst_root), 328 new_mst_root, 329 ops, ··· 398 pub repo: AtIdentifier, 399 pub collection: Nsid, 400 pub rkey: Rkey, 401 + #[serde(default, deserialize_with = "deserialize_validation_mode")] 402 + pub validate: ValidationMode, 403 pub record: serde_json::Value, 404 #[serde(rename = "swapCommit")] 405 pub swap_commit: Option<String>, ··· 421 auth: Auth<Active>, 422 Json(input): Json<PutRecordInput>, 423 ) -> Result<Response, crate::api::error::ApiError> { 424 + let upsert_proof = match auth.verify_repo_upsert(&input.collection) { 425 Ok(proof) => proof, 426 Err(e) => return Ok(e.into_response()), 427 }; 428 429 + let repo_auth = match prepare_repo_write(&state, &upsert_proof, &input.repo).await { 430 Ok(res) => res, 431 Err(err_res) => return Ok(err_res), 432 }; ··· 437 let controller_did = repo_auth.controller_did; 438 439 if let Some(swap_commit) = &input.swap_commit 440 + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) 441 { 442 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 443 } 444 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 445 + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { 446 Ok(Some(b)) => b, 447 _ => { 448 return Ok( ··· 460 }; 461 let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); 462 let key = format!("{}/{}", input.collection, input.rkey); 463 + let validation_status = if input.validate.should_skip() { 464 None 465 } else { 466 match validate_record_with_status( 467 &input.record, 468 &input.collection, 469 Some(&input.rkey), 470 + input.validate.requires_lexicon(), 471 ) { 472 Ok(status) => Some(status), 473 Err(err_response) => return Ok(*err_response), ··· 584 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 585 let is_update = existing_cid.is_some(); 586 let blob_cids = extract_blob_cids(&input.record); 587 + let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid()) 588 .chain( 589 old_mst_blocks 590 .keys() ··· 598 CommitParams { 599 did: &did, 600 user_id, 601 + current_root_cid: Some(current_root_cid.into_cid()), 602 prev_data_cid: Some(commit.data), 603 new_mst_root, 604 ops: vec![op],
+13 -6
crates/tranquil-pds/src/api/server/account_status.rs
··· 380 "[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}", 381 did 382 ); 383 - if let Err(e) = 384 - crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 385 { 386 warn!( 387 "[MIGRATION] activateAccount: Failed to sequence account activation event: {}", ··· 503 if let Err(e) = crate::api::repo::record::sequence_account_event( 504 &state, 505 &did, 506 - false, 507 - Some("deactivated"), 508 ) 509 .await 510 { ··· 634 error!("DB error deleting account: {:?}", e); 635 return ApiError::InternalError(None).into_response(); 636 } 637 - let account_seq = 638 - crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await; 639 match account_seq { 640 Ok(seq) => { 641 if let Err(e) = state.repo_repo.delete_sequences_except(did, seq).await {
··· 380 "[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}", 381 did 382 ); 383 + if let Err(e) = crate::api::repo::record::sequence_account_event( 384 + &state, 385 + &did, 386 + tranquil_db_traits::AccountStatus::Active, 387 + ) 388 + .await 389 { 390 warn!( 391 "[MIGRATION] activateAccount: Failed to sequence account activation event: {}", ··· 507 if let Err(e) = crate::api::repo::record::sequence_account_event( 508 &state, 509 &did, 510 + tranquil_db_traits::AccountStatus::Deactivated, 511 ) 512 .await 513 { ··· 637 error!("DB error deleting account: {:?}", e); 638 return ApiError::InternalError(None).into_response(); 639 } 640 + let account_seq = crate::api::repo::record::sequence_account_event( 641 + &state, 642 + did, 643 + tranquil_db_traits::AccountStatus::Deleted, 644 + ) 645 + .await; 646 match account_seq { 647 Ok(seq) => { 648 if let Err(e) = state.repo_repo.delete_sequences_except(did, seq).await {
+5 -5
crates/tranquil-pds/src/api/server/app_password.rs
··· 52 .map(|row| AppPassword { 53 name: row.name.clone(), 54 created_at: row.created_at.to_rfc3339(), 55 - privileged: row.privileged, 56 scopes: row.scopes.clone(), 57 created_by_controller: row 58 .created_by_controller_did ··· 119 let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 120 121 let requested = input.scopes.as_deref().unwrap_or("atproto"); 122 - let intersected = intersect_scopes(requested, &granted_scopes); 123 124 if intersected.is_empty() && !granted_scopes.is_empty() { 125 return Err(ApiError::InsufficientScope(None)); ··· 150 ApiError::InternalError(None) 151 })?; 152 153 - let privileged = input.privileged.unwrap_or(false); 154 let created_at = chrono::Utc::now(); 155 156 let create_data = AppPasswordCreate { 157 user_id: user.id, 158 name: name.to_string(), 159 password_hash, 160 - privileged, 161 scopes: final_scopes.clone(), 162 created_by_controller_did: controller_did.clone(), 163 }; ··· 190 name: name.to_string(), 191 password, 192 created_at: created_at.to_rfc3339(), 193 - privileged, 194 scopes: final_scopes, 195 }) 196 .into_response())
··· 52 .map(|row| AppPassword { 53 name: row.name.clone(), 54 created_at: row.created_at.to_rfc3339(), 55 + privileged: row.privilege.is_privileged(), 56 scopes: row.scopes.clone(), 57 created_by_controller: row 58 .created_by_controller_did ··· 119 let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 120 121 let requested = input.scopes.as_deref().unwrap_or("atproto"); 122 + let intersected = intersect_scopes(requested, granted_scopes.as_str()); 123 124 if intersected.is_empty() && !granted_scopes.is_empty() { 125 return Err(ApiError::InsufficientScope(None)); ··· 150 ApiError::InternalError(None) 151 })?; 152 153 + let privilege = tranquil_db_traits::AppPasswordPrivilege::from(input.privileged.unwrap_or(false)); 154 let created_at = chrono::Utc::now(); 155 156 let create_data = AppPasswordCreate { 157 user_id: user.id, 158 name: name.to_string(), 159 password_hash, 160 + privilege, 161 scopes: final_scopes.clone(), 162 created_by_controller_did: controller_did.clone(), 163 }; ··· 190 name: name.to_string(), 191 password, 192 created_at: created_at.to_rfc3339(), 193 + privileged: privilege.is_privileged(), 194 scopes: final_scopes, 195 }) 196 .into_response())
crates/tranquil-pds/src/api/server/email.rs

This file has not been changed.

+2 -2
crates/tranquil-pds/src/api/server/invite.rs
··· 1 - use crate::api::error::DbResultExt; 2 use crate::api::ApiError; 3 use crate::auth::{Admin, Auth, NotTakendown}; 4 use crate::state::AppState; 5 use crate::types::Did; ··· 205 206 let filtered_codes: Vec<_> = codes_info 207 .into_iter() 208 - .filter(|info| !info.disabled) 209 .collect(); 210 211 let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
··· 1 use crate::api::ApiError; 2 + use crate::api::error::DbResultExt; 3 use crate::auth::{Admin, Auth, NotTakendown}; 4 use crate::state::AppState; 5 use crate::types::Did; ··· 205 206 let filtered_codes: Vec<_> = codes_info 207 .into_iter() 208 + .filter(|info| info.state.is_active()) 209 .collect(); 210 211 let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
crates/tranquil-pds/src/api/server/meta.rs

This file has not been changed.

+1 -1
crates/tranquil-pds/src/api/server/migration.rs
··· 1 - use crate::api::error::DbResultExt; 2 use crate::api::ApiError; 3 use crate::auth::{Active, Auth}; 4 use crate::state::AppState; 5 use crate::util::pds_hostname;
··· 1 use crate::api::ApiError; 2 + use crate::api::error::DbResultExt; 3 use crate::auth::{Active, Auth}; 4 use crate::state::AppState; 5 use crate::util::pds_hostname;
+17 -19
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 1 use crate::api::SuccessResponse; 2 use crate::api::error::ApiError; 3 use axum::{ 4 Json, 5 extract::State, ··· 145 return ApiError::InvalidEmail.into_response(); 146 } 147 148 - if let Some(ref code) = input.invite_code { 149 - let valid = state 150 - .infra_repo 151 - .is_invite_code_valid(code) 152 - .await 153 - .unwrap_or(false); 154 - 155 - if !valid { 156 - return ApiError::InvalidInviteCode.into_response(); 157 } 158 } else { 159 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 162 if invite_required { 163 return ApiError::InviteCodeRequired.into_response(); 164 } 165 - } 166 167 let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); 168 let verification_recipient = match verification_channel { ··· 460 { 461 warn!("Failed to sequence identity event for {}: {}", did, e); 462 } 463 - if let Err(e) = 464 - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await 465 { 466 warn!("Failed to sequence account event for {}: {}", did, e); 467 } ··· 517 refresh_jti, 518 access_expires_at: token_meta.expires_at, 519 refresh_expires_at: refresh_expires, 520 - legacy_login: false, 521 mfa_verified: false, 522 scope: None, 523 controller_did: None, ··· 808 let hostname_for_handles = pds_hostname_without_port(); 809 let identifier = input.email.trim().to_lowercase(); 810 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); 811 - let normalized_handle = if identifier.contains('@') || identifier.contains('.') { 812 - identifier.to_string() 813 - } else { 814 - format!("{}.{}", identifier, hostname_for_handles) 815 - }; 816 817 let user = match state 818 .user_repo 819 - .get_user_for_passkey_recovery(identifier, &normalized_handle) 820 .await 821 { 822 Ok(Some(u)) if !u.password_required => u,
··· 1 use crate::api::SuccessResponse; 2 use crate::api::error::ApiError; 3 + use crate::auth::NormalizedLoginIdentifier; 4 use axum::{ 5 Json, 6 extract::State, ··· 146 return ApiError::InvalidEmail.into_response(); 147 } 148 149 + let _validated_invite_code = if let Some(ref code) = input.invite_code { 150 + match state.infra_repo.validate_invite_code(code).await { 151 + Ok(validated) => Some(validated), 152 + Err(_) => return ApiError::InvalidInviteCode.into_response(), 153 } 154 } else { 155 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 158 if invite_required { 159 return ApiError::InviteCodeRequired.into_response(); 160 } 161 + None 162 + }; 163 164 let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); 165 let verification_recipient = match verification_channel { ··· 457 { 458 warn!("Failed to sequence identity event for {}: {}", did, e); 459 } 460 + if let Err(e) = crate::api::repo::record::sequence_account_event( 461 + &state, 462 + &did_typed, 463 + tranquil_db_traits::AccountStatus::Active, 464 + ) 465 + .await 466 { 467 warn!("Failed to sequence account event for {}: {}", did, e); 468 } ··· 518 refresh_jti, 519 access_expires_at: token_meta.expires_at, 520 refresh_expires_at: refresh_expires, 521 + login_type: tranquil_db::LoginType::Modern, 522 mfa_verified: false, 523 scope: None, 524 controller_did: None, ··· 809 let hostname_for_handles = pds_hostname_without_port(); 810 let identifier = input.email.trim().to_lowercase(); 811 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); 812 + let normalized_handle = 813 + NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles); 814 815 let user = match state 816 .user_repo 817 + .get_user_for_passkey_recovery(identifier, normalized_handle.as_str()) 818 .await 819 { 820 Ok(Some(u)) if !u.password_required => u,
crates/tranquil-pds/src/api/server/passkeys.rs

This file has not been changed.

+3 -7
crates/tranquil-pds/src/api/server/password.rs
··· 1 use crate::api::error::{ApiError, DbResultExt}; 2 use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse}; 3 use crate::auth::{ 4 - Active, Auth, require_legacy_session_mfa, require_reauth_window, 5 require_reauth_window_if_available, 6 }; 7 use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit}; ··· 42 let normalized = identifier.to_lowercase(); 43 let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); 44 let is_email_lookup = normalized.contains('@'); 45 - let normalized_handle = if normalized.contains('@') || normalized.contains('.') { 46 - normalized.to_string() 47 - } else { 48 - format!("{}.{}", normalized, hostname_for_handles) 49 - }; 50 51 let multiple_accounts_warning = if is_email_lookup { 52 match state.user_repo.count_accounts_by_email(normalized).await { ··· 59 60 let user_id = match state 61 .user_repo 62 - .get_id_by_email_or_handle(normalized, &normalized_handle) 63 .await 64 { 65 Ok(Some(id)) => id,
··· 1 use crate::api::error::{ApiError, DbResultExt}; 2 use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse}; 3 use crate::auth::{ 4 + Active, Auth, NormalizedLoginIdentifier, require_legacy_session_mfa, require_reauth_window, 5 require_reauth_window_if_available, 6 }; 7 use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit}; ··· 42 let normalized = identifier.to_lowercase(); 43 let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); 44 let is_email_lookup = normalized.contains('@'); 45 + let normalized_handle = NormalizedLoginIdentifier::normalize(identifier, hostname_for_handles); 46 47 let multiple_accounts_warning = if is_email_lookup { 48 match state.user_repo.count_accounts_by_email(normalized).await { ··· 55 56 let user_id = match state 57 .user_repo 58 + .get_id_by_email_or_handle(normalized, normalized_handle.as_str()) 59 .await 60 { 61 Ok(Some(id)) => id,
+1 -1
crates/tranquil-pds/src/api/server/reauth.rs
··· 381 ) -> bool { 382 match session_repo.get_session_mfa_status(did).await { 383 Ok(Some(status)) => { 384 - if !status.legacy_login { 385 return true; 386 } 387 if status.mfa_verified {
··· 381 ) -> bool { 382 match session_repo.get_session_mfa_status(did).await { 383 Ok(Some(status)) => { 384 + if status.login_type.is_modern() { 385 return true; 386 } 387 if status.mfa_verified {
crates/tranquil-pds/src/api/server/service_auth.rs

This file has not been changed.

+43 -44
crates/tranquil-pds/src/api/server/session.rs
··· 1 use crate::api::error::{ApiError, DbResultExt}; 2 use crate::api::{EmptyResponse, SuccessResponse}; 3 - use crate::auth::{Active, Auth, Permissive, require_legacy_session_mfa, require_reauth_window}; 4 use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit}; 5 use crate::state::AppState; 6 use crate::types::{AccountState, Did, Handle, PlainPassword}; ··· 15 use serde::{Deserialize, Serialize}; 16 use serde_json::json; 17 use tracing::{error, info, warn}; 18 use tranquil_types::TokenId; 19 20 - fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { 21 - let identifier = identifier.trim(); 22 - if identifier.contains('@') || identifier.starts_with("did:") { 23 - identifier.to_string() 24 - } else if !identifier.contains('.') { 25 - format!("{}.{}", identifier.to_lowercase(), pds_hostname) 26 - } else { 27 - identifier.to_lowercase() 28 - } 29 - } 30 - 31 fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { 32 stored_handle.to_string() 33 } ··· 72 ); 73 let pds_host = pds_hostname(); 74 let hostname_for_handles = pds_hostname_without_port(); 75 - let normalized_identifier = normalize_handle(&input.identifier, hostname_for_handles); 76 info!( 77 "Normalized identifier: {} -> {}", 78 input.identifier, normalized_identifier 79 ); 80 let row = match state 81 .user_repo 82 - .get_login_full_by_identifier(&normalized_identifier) 83 .await 84 { 85 Ok(Some(row)) => row, ··· 145 warn!("Login attempt for takendown account: {}", row.did); 146 return ApiError::AccountTakedown.into_response(); 147 } 148 - let is_verified = 149 - row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 150 let is_delegated = state 151 .delegation_repo 152 .is_delegated_account(&row.did) ··· 206 refresh_jti: refresh_meta.jti.clone(), 207 access_expires_at: access_meta.expires_at, 208 refresh_expires_at: refresh_meta.expires_at, 209 - legacy_login: is_legacy_login, 210 mfa_verified: false, 211 scope: app_password_scopes.clone(), 212 controller_did: app_password_controller.clone(), ··· 250 did: row.did, 251 did_doc, 252 email: row.email, 253 - email_confirmed: Some(row.email_verified), 254 active: Some(is_active), 255 status, 256 }) ··· 272 ); 273 match db_result { 274 Ok(Some(row)) => { 275 - let (preferred_channel, preferred_channel_verified) = match row.preferred_comms_channel 276 - { 277 - tranquil_db_traits::CommsChannel::Email => ("email", row.email_verified), 278 - tranquil_db_traits::CommsChannel::Discord => ("discord", row.discord_verified), 279 - tranquil_db_traits::CommsChannel::Telegram => ("telegram", row.telegram_verified), 280 - tranquil_db_traits::CommsChannel::Signal => ("signal", row.signal_verified), 281 }; 282 let pds_hostname = pds_hostname(); 283 let handle = full_handle(&row.handle, pds_hostname); 284 let account_state = AccountState::from_db_fields( ··· 292 } else { 293 None 294 }; 295 - let email_confirmed_value = can_read_email && row.email_verified; 296 let mut response = json!({ 297 "handle": handle, 298 "did": &auth.did, ··· 331 headers: axum::http::HeaderMap, 332 _auth: Auth<Active>, 333 ) -> Result<Response, ApiError> { 334 - let extracted = crate::auth::extract_auth_token_from_header( 335 - crate::util::get_header_str(&headers, "Authorization"), 336 - ) 337 .ok_or(ApiError::AuthenticationRequired)?; 338 let jti = crate::auth::get_jti_from_token(&extracted.token) 339 .map_err(|_| ApiError::AuthenticationFailed(None))?; ··· 356 _rate_limit: RateLimited<RefreshSessionLimit>, 357 headers: axum::http::HeaderMap, 358 ) -> Response { 359 - let extracted = match crate::auth::extract_auth_token_from_header( 360 - crate::util::get_header_str(&headers, "Authorization"), 361 - ) { 362 Some(t) => t, 363 None => return ApiError::AuthenticationRequired.into_response(), 364 }; ··· 475 ); 476 match db_result { 477 Ok(Some(u)) => { 478 - let (preferred_channel, preferred_channel_verified) = match u.preferred_comms_channel { 479 - tranquil_db_traits::CommsChannel::Email => ("email", u.email_verified), 480 - tranquil_db_traits::CommsChannel::Discord => ("discord", u.discord_verified), 481 - tranquil_db_traits::CommsChannel::Telegram => ("telegram", u.telegram_verified), 482 - tranquil_db_traits::CommsChannel::Signal => ("signal", u.signal_verified), 483 }; 484 let pds_hostname = pds_hostname(); 485 let handle = full_handle(&u.handle, pds_hostname); 486 let account_state = ··· 491 "handle": handle, 492 "did": session_row.did, 493 "email": u.email, 494 - "emailConfirmed": u.email_verified, 495 "preferredChannel": preferred_channel, 496 "preferredChannelVerified": preferred_channel_verified, 497 "preferredLocale": u.preferred_locale, ··· 635 refresh_jti: refresh_meta.jti.clone(), 636 access_expires_at: access_meta.expires_at, 637 refresh_expires_at: refresh_meta.expires_at, 638 - legacy_login: false, 639 mfa_verified: false, 640 scope: None, 641 controller_did: None, ··· 702 return ApiError::InternalError(None).into_response(); 703 } 704 }; 705 - let is_verified = 706 - row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 707 if is_verified { 708 return ApiError::InvalidRequest("Account is already verified".into()).into_response(); 709 } ··· 834 Json(input): Json<RevokeSessionInput>, 835 ) -> Result<Response, ApiError> { 836 if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") { 837 - let session_id: i32 = jwt_id 838 - .parse() 839 .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; 840 let access_jti = state 841 .session_repo ··· 854 } 855 info!(did = %&auth.did, session_id = %session_id, "JWT session revoked"); 856 } else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") { 857 - let session_id: i32 = oauth_id 858 - .parse() 859 .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; 860 let deleted = state 861 .oauth_repo
··· 1 use crate::api::error::{ApiError, DbResultExt}; 2 use crate::api::{EmptyResponse, SuccessResponse}; 3 + use crate::auth::{ 4 + Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa, 5 + require_reauth_window, 6 + }; 7 use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit}; 8 use crate::state::AppState; 9 use crate::types::{AccountState, Did, Handle, PlainPassword}; ··· 18 use serde::{Deserialize, Serialize}; 19 use serde_json::json; 20 use tracing::{error, info, warn}; 21 + use tranquil_db_traits::{SessionId, TokenFamilyId}; 22 use tranquil_types::TokenId; 23 24 fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { 25 stored_handle.to_string() 26 } ··· 65 ); 66 let pds_host = pds_hostname(); 67 let hostname_for_handles = pds_hostname_without_port(); 68 + let normalized_identifier = 69 + NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles); 70 info!( 71 "Normalized identifier: {} -> {}", 72 input.identifier, normalized_identifier 73 ); 74 let row = match state 75 .user_repo 76 + .get_login_full_by_identifier(normalized_identifier.as_str()) 77 .await 78 { 79 Ok(Some(row)) => row, ··· 139 warn!("Login attempt for takendown account: {}", row.did); 140 return ApiError::AccountTakedown.into_response(); 141 } 142 + let is_verified = row.channel_verification.has_any_verified(); 143 let is_delegated = state 144 .delegation_repo 145 .is_delegated_account(&row.did) ··· 199 refresh_jti: refresh_meta.jti.clone(), 200 access_expires_at: access_meta.expires_at, 201 refresh_expires_at: refresh_meta.expires_at, 202 + login_type: tranquil_db_traits::LoginType::from(is_legacy_login), 203 mfa_verified: false, 204 scope: app_password_scopes.clone(), 205 controller_did: app_password_controller.clone(), ··· 243 did: row.did, 244 did_doc, 245 email: row.email, 246 + email_confirmed: Some(row.channel_verification.email), 247 active: Some(is_active), 248 status, 249 }) ··· 265 ); 266 match db_result { 267 Ok(Some(row)) => { 268 + let preferred_channel = match row.preferred_comms_channel { 269 + tranquil_db_traits::CommsChannel::Email => "email", 270 + tranquil_db_traits::CommsChannel::Discord => "discord", 271 + tranquil_db_traits::CommsChannel::Telegram => "telegram", 272 + tranquil_db_traits::CommsChannel::Signal => "signal", 273 }; 274 + let preferred_channel_verified = 275 + row.channel_verification.is_verified(row.preferred_comms_channel); 276 let pds_hostname = pds_hostname(); 277 let handle = full_handle(&row.handle, pds_hostname); 278 let account_state = AccountState::from_db_fields( ··· 286 } else { 287 None 288 }; 289 + let email_confirmed_value = can_read_email && row.channel_verification.email; 290 let mut response = json!({ 291 "handle": handle, 292 "did": &auth.did, ··· 325 headers: axum::http::HeaderMap, 326 _auth: Auth<Active>, 327 ) -> Result<Response, ApiError> { 328 + let extracted = crate::auth::extract_auth_token_from_header(crate::util::get_header_str( 329 + &headers, 330 + "Authorization", 331 + )) 332 .ok_or(ApiError::AuthenticationRequired)?; 333 let jti = crate::auth::get_jti_from_token(&extracted.token) 334 .map_err(|_| ApiError::AuthenticationFailed(None))?; ··· 351 _rate_limit: RateLimited<RefreshSessionLimit>, 352 headers: axum::http::HeaderMap, 353 ) -> Response { 354 + let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str( 355 + &headers, 356 + "Authorization", 357 + )) { 358 Some(t) => t, 359 None => return ApiError::AuthenticationRequired.into_response(), 360 }; ··· 471 ); 472 match db_result { 473 Ok(Some(u)) => { 474 + let preferred_channel = match u.preferred_comms_channel { 475 + tranquil_db_traits::CommsChannel::Email => "email", 476 + tranquil_db_traits::CommsChannel::Discord => "discord", 477 + tranquil_db_traits::CommsChannel::Telegram => "telegram", 478 + tranquil_db_traits::CommsChannel::Signal => "signal", 479 }; 480 + let preferred_channel_verified = 481 + u.channel_verification.is_verified(u.preferred_comms_channel); 482 let pds_hostname = pds_hostname(); 483 let handle = full_handle(&u.handle, pds_hostname); 484 let account_state = ··· 489 "handle": handle, 490 "did": session_row.did, 491 "email": u.email, 492 + "emailConfirmed": u.channel_verification.email, 493 "preferredChannel": preferred_channel, 494 "preferredChannelVerified": preferred_channel_verified, 495 "preferredLocale": u.preferred_locale, ··· 633 refresh_jti: refresh_meta.jti.clone(), 634 access_expires_at: access_meta.expires_at, 635 refresh_expires_at: refresh_meta.expires_at, 636 + login_type: tranquil_db_traits::LoginType::Modern, 637 mfa_verified: false, 638 scope: None, 639 controller_did: None, ··· 700 return ApiError::InternalError(None).into_response(); 701 } 702 }; 703 + let is_verified = row.channel_verification.has_any_verified(); 704 if is_verified { 705 return ApiError::InvalidRequest("Account is already verified".into()).into_response(); 706 } ··· 831 Json(input): Json<RevokeSessionInput>, 832 ) -> Result<Response, ApiError> { 833 if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") { 834 + let session_id = jwt_id 835 + .parse::<i32>() 836 + .map(SessionId::new) 837 .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; 838 let access_jti = state 839 .session_repo ··· 852 } 853 info!(did = %&auth.did, session_id = %session_id, "JWT session revoked"); 854 } else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") { 855 + let session_id = oauth_id 856 + .parse::<i32>() 857 + .map(TokenFamilyId::new) 858 .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; 859 let deleted = state 860 .oauth_repo
+26 -21
crates/tranquil-pds/src/api/server/totp.rs
··· 32 State(state): State<AppState>, 33 auth: Auth<Active>, 34 ) -> Result<Response, ApiError> { 35 - match state.user_repo.get_totp_record(&auth.did).await { 36 - Ok(Some(record)) if record.verified => return Err(ApiError::TotpAlreadyEnabled), 37 - Ok(_) => {} 38 Err(e) => { 39 error!("DB error checking TOTP: {:?}", e); 40 return Err(ApiError::InternalError(None)); ··· 97 auth: Auth<Active>, 98 Json(input): Json<EnableTotpInput>, 99 ) -> Result<Response, ApiError> { 100 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 101 &state, 102 &auth.did, ··· 104 ) 105 .await?; 106 107 - let totp_record = match state.user_repo.get_totp_record(&auth.did).await { 108 - Ok(Some(row)) => row, 109 Ok(None) => return Err(ApiError::TotpNotEnabled), 110 Err(e) => { 111 error!("DB error fetching TOTP: {:?}", e); ··· 113 } 114 }; 115 116 - if totp_record.verified { 117 - return Err(ApiError::TotpAlreadyEnabled); 118 - } 119 - 120 let secret = decrypt_totp_secret( 121 - &totp_record.secret_encrypted, 122 - totp_record.encryption_version, 123 ) 124 .map_err(|e| { 125 error!("Failed to decrypt TOTP secret: {:?}", e); ··· 165 auth: Auth<Active>, 166 Json(input): Json<DisableTotpInput>, 167 ) -> Result<Response, ApiError> { 168 - let _session_mfa = match require_legacy_session_mfa(&state, &auth).await { 169 Ok(proof) => proof, 170 Err(response) => return Ok(response), 171 }; 172 173 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 174 &state, 175 - &auth.did, 176 "Too many verification attempts. Please try again in a few minutes.", 177 ) 178 .await?; ··· 186 .await 187 .log_db_err("deleting TOTP")?; 188 189 - info!(did = %password_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method()); 190 191 Ok(EmptyResponse::ok().into_response()) 192 } ··· 203 State(state): State<AppState>, 204 auth: Auth<Active>, 205 ) -> Result<Response, ApiError> { 206 - let enabled = match state.user_repo.get_totp_record(&auth.did).await { 207 - Ok(Some(row)) => row.verified, 208 - Ok(None) => false, 209 Err(e) => { 210 error!("DB error fetching TOTP status: {:?}", e); 211 return Err(ApiError::InternalError(None)); ··· 307 did: &crate::types::Did, 308 code: &str, 309 ) -> bool { 310 let code = code.trim(); 311 312 if is_backup_code_format(code) { 313 return verify_backup_code_for_user(state, did, code).await; 314 } 315 316 - let totp_record = match state.user_repo.get_totp_record(did).await { 317 - Ok(Some(row)) if row.verified => row, 318 _ => return false, 319 }; 320 321 let secret = match decrypt_totp_secret( 322 - &totp_record.secret_encrypted, 323 - totp_record.encryption_version, 324 ) { 325 Ok(s) => s, 326 Err(_) => return false,
··· 32 State(state): State<AppState>, 33 auth: Auth<Active>, 34 ) -> Result<Response, ApiError> { 35 + use tranquil_db_traits::TotpRecordState; 36 + 37 + match state.user_repo.get_totp_record_state(&auth.did).await { 38 + Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled), 39 + Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => {} 40 Err(e) => { 41 error!("DB error checking TOTP: {:?}", e); 42 return Err(ApiError::InternalError(None)); ··· 99 auth: Auth<Active>, 100 Json(input): Json<EnableTotpInput>, 101 ) -> Result<Response, ApiError> { 102 + use tranquil_db_traits::TotpRecordState; 103 + 104 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 105 &state, 106 &auth.did, ··· 108 ) 109 .await?; 110 111 + let unverified_record = match state.user_repo.get_totp_record_state(&auth.did).await { 112 + Ok(Some(TotpRecordState::Unverified(record))) => record, 113 + Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled), 114 Ok(None) => return Err(ApiError::TotpNotEnabled), 115 Err(e) => { 116 error!("DB error fetching TOTP: {:?}", e); ··· 118 } 119 }; 120 121 let secret = decrypt_totp_secret( 122 + &unverified_record.secret_encrypted, 123 + unverified_record.encryption_version, 124 ) 125 .map_err(|e| { 126 error!("Failed to decrypt TOTP secret: {:?}", e); ··· 166 auth: Auth<Active>, 167 Json(input): Json<DisableTotpInput>, 168 ) -> Result<Response, ApiError> { 169 + let session_mfa = match require_legacy_session_mfa(&state, &auth).await { 170 Ok(proof) => proof, 171 Err(response) => return Ok(response), 172 }; 173 174 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 175 &state, 176 + session_mfa.did(), 177 "Too many verification attempts. Please try again in a few minutes.", 178 ) 179 .await?; ··· 187 .await 188 .log_db_err("deleting TOTP")?; 189 190 + info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method()); 191 192 Ok(EmptyResponse::ok().into_response()) 193 } ··· 204 State(state): State<AppState>, 205 auth: Auth<Active>, 206 ) -> Result<Response, ApiError> { 207 + use tranquil_db_traits::TotpRecordState; 208 + 209 + let enabled = match state.user_repo.get_totp_record_state(&auth.did).await { 210 + Ok(Some(TotpRecordState::Verified(_))) => true, 211 + Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => false, 212 Err(e) => { 213 error!("DB error fetching TOTP status: {:?}", e); 214 return Err(ApiError::InternalError(None)); ··· 310 did: &crate::types::Did, 311 code: &str, 312 ) -> bool { 313 + use tranquil_db_traits::TotpRecordState; 314 + 315 let code = code.trim(); 316 317 if is_backup_code_format(code) { 318 return verify_backup_code_for_user(state, did, code).await; 319 } 320 321 + let verified_record = match state.user_repo.get_totp_record_state(did).await { 322 + Ok(Some(TotpRecordState::Verified(record))) => record, 323 _ => return false, 324 }; 325 326 let secret = match decrypt_totp_secret( 327 + &verified_record.secret_encrypted, 328 + verified_record.encryption_version, 329 ) { 330 Ok(s) => s, 331 Err(_) => return false,
crates/tranquil-pds/src/api/server/trusted_devices.rs

This file has not been changed.

crates/tranquil-pds/src/api/server/verify_email.rs

This file has not been changed.

+2 -5
crates/tranquil-pds/src/api/server/verify_token.rs
··· 88 return Err(ApiError::IdentifierMismatch); 89 } 90 91 - if !user.email_verified { 92 state 93 .user_repo 94 .set_email_verified_flag(user.id) ··· 185 .log_db_err("during signup verification")? 186 .ok_or(ApiError::AccountNotFound)?; 187 188 - let is_verified = user.email_verified 189 - || user.discord_verified 190 - || user.telegram_verified 191 - || user.signal_verified; 192 if is_verified { 193 info!(did = %did, "Account already verified"); 194 return Ok(Json(VerifyTokenOutput {
··· 88 return Err(ApiError::IdentifierMismatch); 89 } 90 91 + if !user.channel_verification.email { 92 state 93 .user_repo 94 .set_email_verified_flag(user.id) ··· 185 .log_db_err("during signup verification")? 186 .ok_or(ApiError::AccountNotFound)?; 187 188 + let is_verified = user.channel_verification.has_any_verified(); 189 if is_verified { 190 info!(did = %did, "Account already verified"); 191 return Ok(Json(VerifyTokenOutput {
crates/tranquil-pds/src/auth/extractor.rs

This file has not been changed.

+18 -7
crates/tranquil-pds/src/auth/mfa_verified.rs
··· 87 state: &AppState, 88 user: &'a AuthenticatedUser, 89 ) -> Result<MfaVerified<'a>, Response> { 90 - use chrono::Utc; 91 use crate::api::server::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response}; 92 93 - let status = state.session_repo.get_session_mfa_status(&user.did).await.ok().flatten(); 94 95 match status { 96 Some(s) => { ··· 177 code: &str, 178 ) -> Result<MfaVerified<'a>, crate::api::error::ApiError> { 179 use crate::auth::{decrypt_totp_secret, is_backup_code_format, verify_totp_code}; 180 181 let code = code.trim(); 182 183 if is_backup_code_format(code) { 184 - let backup_codes = state.user_repo.get_unused_backup_codes(&user.did).await.ok().unwrap_or_default(); 185 let code_upper = code.to_uppercase(); 186 187 let matched = backup_codes ··· 199 }; 200 } 201 202 - let totp_record = match state.user_repo.get_totp_record(&user.did).await { 203 - Ok(Some(row)) if row.verified => row, 204 _ => { 205 return Err(crate::api::error::ApiError::TotpNotEnabled); 206 } 207 }; 208 209 let secret = decrypt_totp_secret( 210 - &totp_record.secret_encrypted, 211 - totp_record.encryption_version, 212 ) 213 .map_err(|_| crate::api::error::ApiError::InternalError(None))?; 214
··· 87 state: &AppState, 88 user: &'a AuthenticatedUser, 89 ) -> Result<MfaVerified<'a>, Response> { 90 use crate::api::server::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response}; 91 + use chrono::Utc; 92 93 + let status = state 94 + .session_repo 95 + .get_session_mfa_status(&user.did) 96 + .await 97 + .ok() 98 + .flatten(); 99 100 match status { 101 Some(s) => { ··· 182 code: &str, 183 ) -> Result<MfaVerified<'a>, crate::api::error::ApiError> { 184 use crate::auth::{decrypt_totp_secret, is_backup_code_format, verify_totp_code}; 185 + use tranquil_db_traits::TotpRecordState; 186 187 let code = code.trim(); 188 189 if is_backup_code_format(code) { 190 + let backup_codes = state 191 + .user_repo 192 + .get_unused_backup_codes(&user.did) 193 + .await 194 + .ok() 195 + .unwrap_or_default(); 196 let code_upper = code.to_uppercase(); 197 198 let matched = backup_codes ··· 210 }; 211 } 212 213 + let verified_record = match state.user_repo.get_totp_record_state(&user.did).await { 214 + Ok(Some(TotpRecordState::Verified(record))) => record, 215 _ => { 216 return Err(crate::api::error::ApiError::TotpNotEnabled); 217 } 218 }; 219 220 let secret = decrypt_totp_secret( 221 + &verified_record.secret_encrypted, 222 + verified_record.encryption_version, 223 ) 224 .map_err(|_| crate::api::error::ApiError::InternalError(None))?; 225
+9 -2
crates/tranquil-pds/src/auth/mod.rs
··· 10 use tranquil_db::UserRepository; 11 use tranquil_db_traits::OAuthRepository; 12 13 pub mod extractor; 14 pub mod mfa_verified; 15 pub mod scope_check; 16 pub mod scope_verified; ··· 18 pub mod verification_token; 19 pub mod webauthn; 20 21 pub use extractor::{ 22 Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown, 23 Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header, ··· 27 require_reauth_window_if_available, verify_password_mfa, verify_totp_mfa, 28 }; 29 pub use scope_verified::{ 30 - AccountManage, AccountRead, BlobUpload, IdentityAccess, RepoCreate, RepoDelete, RepoUpdate, 31 - RpcCall, ScopeAction, ScopeVerificationError, ScopeVerified, VerifyScope, 32 }; 33 pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; 34
··· 10 use tranquil_db::UserRepository; 11 use tranquil_db_traits::OAuthRepository; 12 13 + pub mod account_verified; 14 pub mod extractor; 15 + pub mod login_identifier; 16 pub mod mfa_verified; 17 pub mod scope_check; 18 pub mod scope_verified; ··· 20 pub mod verification_token; 21 pub mod webauthn; 22 23 + pub use login_identifier::{BareLoginIdentifier, NormalizedLoginIdentifier}; 24 + 25 + pub use account_verified::{AccountVerified, require_not_migrated, require_verified_or_delegated}; 26 pub use extractor::{ 27 Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown, 28 Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header, ··· 32 require_reauth_window_if_available, verify_password_mfa, verify_totp_mfa, 33 }; 34 pub use scope_verified::{ 35 + AccountManage, AccountRead, BatchWriteScopes, BlobScopeAction, BlobUpload, ControllerDid, 36 + IdentityAccess, PrincipalDid, RepoCreate, RepoDelete, RepoScopeAction, RepoUpdate, RepoUpsert, 37 + RpcCall, ScopeAction, ScopeVerificationError, ScopeVerified, VerifyScope, WriteOpKind, 38 + verify_batch_write_scopes, 39 }; 40 pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; 41
+230 -5
crates/tranquil-pds/src/auth/scope_verified.rs
··· 1 use std::marker::PhantomData; 2 3 use axum::response::{IntoResponse, Response}; 4 5 use crate::api::error::ApiError; 6 - use crate::oauth::scopes::{AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions}; 7 8 use super::AuthenticatedUser; 9 10 #[derive(Debug)] 11 pub struct ScopeVerificationError { 12 message: String, ··· 40 41 mod private { 42 pub trait Sealed {} 43 } 44 45 pub trait ScopeAction: private::Sealed {} 46 47 pub struct RepoCreate; 48 pub struct RepoUpdate; 49 pub struct RepoDelete; 50 pub struct BlobUpload; 51 pub struct RpcCall; 52 pub struct AccountRead; ··· 56 impl private::Sealed for RepoCreate {} 57 impl private::Sealed for RepoUpdate {} 58 impl private::Sealed for RepoDelete {} 59 impl private::Sealed for BlobUpload {} 60 impl private::Sealed for RpcCall {} 61 impl private::Sealed for AccountRead {} 62 impl private::Sealed for AccountManage {} 63 impl private::Sealed for IdentityAccess {} 64 65 impl ScopeAction for RepoCreate {} 66 impl ScopeAction for RepoUpdate {} 67 impl ScopeAction for RepoDelete {} 68 impl ScopeAction for BlobUpload {} 69 impl ScopeAction for RpcCall {} 70 impl ScopeAction for AccountRead {} 71 impl ScopeAction for AccountManage {} 72 impl ScopeAction for IdentityAccess {} 73 74 pub struct ScopeVerified<'a, A: ScopeAction> { 75 user: &'a AuthenticatedUser, 76 _action: PhantomData<A>, ··· 81 self.user 82 } 83 84 - pub fn did(&self) -> &crate::types::Did { 85 - &self.user.did 86 } 87 88 pub fn is_admin(&self) -> bool { 89 self.user.is_admin 90 } 91 92 - pub fn controller_did(&self) -> Option<&crate::types::Did> { 93 - self.user.controller_did.as_ref() 94 } 95 } 96 97 pub trait VerifyScope { 98 fn needs_scope_check(&self) -> bool; 99 fn permissions(&self) -> ScopePermissions; ··· 157 } 158 self.permissions() 159 .assert_repo(RepoAction::Delete, collection) 160 .map_err(|e| ScopeVerificationError::new(e.to_string()))?; 161 Ok(ScopeVerified { 162 user: self.as_ref(),
··· 1 use std::marker::PhantomData; 2 + use std::ops::Deref; 3 4 use axum::response::{IntoResponse, Response}; 5 6 use crate::api::error::ApiError; 7 + use crate::oauth::scopes::{ 8 + AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, 9 + }; 10 + use crate::types::Did; 11 12 use super::AuthenticatedUser; 13 14 + #[derive(Debug, Clone)] 15 + pub struct PrincipalDid(Did); 16 + 17 + impl PrincipalDid { 18 + pub fn as_did(&self) -> &Did { 19 + &self.0 20 + } 21 + 22 + pub fn into_did(self) -> Did { 23 + self.0 24 + } 25 + 26 + pub fn as_str(&self) -> &str { 27 + self.0.as_str() 28 + } 29 + } 30 + 31 + impl Deref for PrincipalDid { 32 + type Target = Did; 33 + 34 + fn deref(&self) -> &Self::Target { 35 + &self.0 36 + } 37 + } 38 + 39 + impl AsRef<Did> for PrincipalDid { 40 + fn as_ref(&self) -> &Did { 41 + &self.0 42 + } 43 + } 44 + 45 + impl std::fmt::Display for PrincipalDid { 46 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 47 + self.0.fmt(f) 48 + } 49 + } 50 + 51 + #[derive(Debug, Clone)] 52 + pub struct ControllerDid(Did); 53 + 54 + impl ControllerDid { 55 + pub fn as_did(&self) -> &Did { 56 + &self.0 57 + } 58 + 59 + pub fn into_did(self) -> Did { 60 + self.0 61 + } 62 + 63 + pub fn as_str(&self) -> &str { 64 + self.0.as_str() 65 + } 66 + } 67 + 68 + impl Deref for ControllerDid { 69 + type Target = Did; 70 + 71 + fn deref(&self) -> &Self::Target { 72 + &self.0 73 + } 74 + } 75 + 76 + impl AsRef<Did> for ControllerDid { 77 + fn as_ref(&self) -> &Did { 78 + &self.0 79 + } 80 + } 81 + 82 + impl std::fmt::Display for ControllerDid { 83 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 84 + self.0.fmt(f) 85 + } 86 + } 87 + 88 #[derive(Debug)] 89 pub struct ScopeVerificationError { 90 message: String, ··· 118 119 mod private { 120 pub trait Sealed {} 121 + pub trait RepoScopeSealed {} 122 + pub trait BlobScopeSealed {} 123 } 124 125 pub trait ScopeAction: private::Sealed {} 126 127 + pub trait RepoScopeAction: ScopeAction + private::RepoScopeSealed {} 128 + 129 + pub trait BlobScopeAction: ScopeAction + private::BlobScopeSealed {} 130 + 131 pub struct RepoCreate; 132 pub struct RepoUpdate; 133 pub struct RepoDelete; 134 + pub struct RepoUpsert; 135 pub struct BlobUpload; 136 pub struct RpcCall; 137 pub struct AccountRead; ··· 141 impl private::Sealed for RepoCreate {} 142 impl private::Sealed for RepoUpdate {} 143 impl private::Sealed for RepoDelete {} 144 + impl private::Sealed for RepoUpsert {} 145 impl private::Sealed for BlobUpload {} 146 impl private::Sealed for RpcCall {} 147 impl private::Sealed for AccountRead {} 148 impl private::Sealed for AccountManage {} 149 impl private::Sealed for IdentityAccess {} 150 151 + impl private::RepoScopeSealed for RepoCreate {} 152 + impl private::RepoScopeSealed for RepoUpdate {} 153 + impl private::RepoScopeSealed for RepoDelete {} 154 + impl private::RepoScopeSealed for RepoUpsert {} 155 + 156 + impl private::BlobScopeSealed for BlobUpload {} 157 + 158 impl ScopeAction for RepoCreate {} 159 impl ScopeAction for RepoUpdate {} 160 impl ScopeAction for RepoDelete {} 161 + impl ScopeAction for RepoUpsert {} 162 impl ScopeAction for BlobUpload {} 163 impl ScopeAction for RpcCall {} 164 impl ScopeAction for AccountRead {} 165 impl ScopeAction for AccountManage {} 166 impl ScopeAction for IdentityAccess {} 167 168 + impl RepoScopeAction for RepoCreate {} 169 + impl RepoScopeAction for RepoUpdate {} 170 + impl RepoScopeAction for RepoDelete {} 171 + impl RepoScopeAction for RepoUpsert {} 172 + 173 + impl BlobScopeAction for BlobUpload {} 174 + 175 pub struct ScopeVerified<'a, A: ScopeAction> { 176 user: &'a AuthenticatedUser, 177 _action: PhantomData<A>, ··· 182 self.user 183 } 184 185 + pub fn principal_did(&self) -> PrincipalDid { 186 + PrincipalDid(self.user.did.clone()) 187 } 188 189 + pub fn controller_did(&self) -> Option<ControllerDid> { 190 + self.user.controller_did.clone().map(ControllerDid) 191 + } 192 + 193 pub fn is_admin(&self) -> bool { 194 self.user.is_admin 195 } 196 + } 197 198 + pub struct BatchWriteScopes<'a> { 199 + user: &'a AuthenticatedUser, 200 + has_creates: bool, 201 + has_updates: bool, 202 + has_deletes: bool, 203 + } 204 + 205 + impl<'a> BatchWriteScopes<'a> { 206 + pub fn principal_did(&self) -> PrincipalDid { 207 + PrincipalDid(self.user.did.clone()) 208 } 209 + 210 + pub fn controller_did(&self) -> Option<ControllerDid> { 211 + self.user.controller_did.clone().map(ControllerDid) 212 + } 213 + 214 + pub fn user(&self) -> &AuthenticatedUser { 215 + self.user 216 + } 217 + 218 + pub fn has_creates(&self) -> bool { 219 + self.has_creates 220 + } 221 + 222 + pub fn has_updates(&self) -> bool { 223 + self.has_updates 224 + } 225 + 226 + pub fn has_deletes(&self) -> bool { 227 + self.has_deletes 228 + } 229 } 230 231 + pub fn verify_batch_write_scopes<'a, T, C, F>( 232 + auth: &'a impl VerifyScope, 233 + user: &'a AuthenticatedUser, 234 + writes: &[T], 235 + get_collection: F, 236 + classify: C, 237 + ) -> Result<BatchWriteScopes<'a>, ScopeVerificationError> 238 + where 239 + F: Fn(&T) -> &str, 240 + C: Fn(&T) -> WriteOpKind, 241 + { 242 + use std::collections::HashSet; 243 + 244 + let create_collections: HashSet<&str> = writes 245 + .iter() 246 + .filter(|w| matches!(classify(w), WriteOpKind::Create)) 247 + .map(&get_collection) 248 + .collect(); 249 + 250 + let update_collections: HashSet<&str> = writes 251 + .iter() 252 + .filter(|w| matches!(classify(w), WriteOpKind::Update)) 253 + .map(&get_collection) 254 + .collect(); 255 + 256 + let delete_collections: HashSet<&str> = writes 257 + .iter() 258 + .filter(|w| matches!(classify(w), WriteOpKind::Delete)) 259 + .map(&get_collection) 260 + .collect(); 261 + 262 + if auth.needs_scope_check() { 263 + create_collections.iter().try_for_each(|c| { 264 + auth.permissions() 265 + .assert_repo(RepoAction::Create, c) 266 + .map_err(|e| ScopeVerificationError::new(e.to_string())) 267 + })?; 268 + 269 + update_collections.iter().try_for_each(|c| { 270 + auth.permissions() 271 + .assert_repo(RepoAction::Update, c) 272 + .map_err(|e| ScopeVerificationError::new(e.to_string())) 273 + })?; 274 + 275 + delete_collections.iter().try_for_each(|c| { 276 + auth.permissions() 277 + .assert_repo(RepoAction::Delete, c) 278 + .map_err(|e| ScopeVerificationError::new(e.to_string())) 279 + })?; 280 + } 281 + 282 + Ok(BatchWriteScopes { 283 + user, 284 + has_creates: !create_collections.is_empty(), 285 + has_updates: !update_collections.is_empty(), 286 + has_deletes: !delete_collections.is_empty(), 287 + }) 288 + } 289 + 290 + #[derive(Clone, Copy)] 291 + pub enum WriteOpKind { 292 + Create, 293 + Update, 294 + Delete, 295 + } 296 + 297 pub trait VerifyScope { 298 fn needs_scope_check(&self) -> bool; 299 fn permissions(&self) -> ScopePermissions; ··· 357 } 358 self.permissions() 359 .assert_repo(RepoAction::Delete, collection) 360 + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; 361 + Ok(ScopeVerified { 362 + user: self.as_ref(), 363 + _action: PhantomData, 364 + }) 365 + } 366 + 367 + fn verify_repo_upsert<'a>( 368 + &'a self, 369 + collection: &str, 370 + ) -> Result<ScopeVerified<'a, RepoUpsert>, ScopeVerificationError> 371 + where 372 + Self: AsRef<AuthenticatedUser>, 373 + { 374 + if !self.needs_scope_check() { 375 + return Ok(ScopeVerified { 376 + user: self.as_ref(), 377 + _action: PhantomData, 378 + }); 379 + } 380 + self.permissions() 381 + .assert_repo(RepoAction::Create, collection) 382 + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; 383 + self.permissions() 384 + .assert_repo(RepoAction::Update, collection) 385 .map_err(|e| ScopeVerificationError::new(e.to_string()))?; 386 Ok(ScopeVerified { 387 user: self.as_ref(),
+1 -2
crates/tranquil-pds/src/auth/service.rs
··· 80 let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") 81 .unwrap_or_else(|_| "https://plc.directory".to_string()); 82 83 - let pds_hostname = 84 - pds_hostname(); 85 let pds_did = format!("did:web:{}", pds_hostname); 86 87 let client = Client::builder()
··· 80 let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") 81 .unwrap_or_else(|_| "https://plc.directory".to_string()); 82 83 + let pds_hostname = pds_hostname(); 84 let pds_did = format!("did:web:{}", pds_hostname); 85 86 let client = Client::builder()
crates/tranquil-pds/src/auth/webauthn.rs

This file has not been changed.

+2 -1
crates/tranquil-pds/src/crawlers.rs
··· 7 use std::time::Duration; 8 use tokio::sync::{broadcast, watch}; 9 use tracing::{debug, error, info, warn}; 10 11 const NOTIFY_THRESHOLD_SECS: u64 = 20 * 60; 12 ··· 161 result = firehose_rx.recv() => { 162 match result { 163 Ok(event) => { 164 - if event.event_type == "commit" { 165 crawlers.notify_of_update().await; 166 } 167 }
··· 7 use std::time::Duration; 8 use tokio::sync::{broadcast, watch}; 9 use tracing::{debug, error, info, warn}; 10 + use tranquil_db_traits::RepoEventType; 11 12 const NOTIFY_THRESHOLD_SECS: u64 = 20 * 60; 13 ··· 162 result = firehose_rx.recv() => { 163 match result { 164 Ok(event) => { 165 + if event.event_type == RepoEventType::Commit { 166 crawlers.notify_of_update().await; 167 } 168 }
+6 -3
crates/tranquil-pds/src/delegation/mod.rs
··· 2 pub mod scopes; 3 4 pub use roles::{ 5 - CanAddControllers, CanControlAccounts, verify_can_add_controllers, verify_can_be_controller, 6 - verify_can_control_accounts, 7 }; 8 - pub use scopes::{SCOPE_PRESETS, ScopePreset, intersect_scopes}; 9 pub use tranquil_db_traits::DelegationActionType;
··· 2 pub mod scopes; 3 4 pub use roles::{ 5 + CanAddControllers, CanBeController, CanControlAccounts, verify_can_add_controllers, 6 + verify_can_be_controller, verify_can_control_accounts, 7 }; 8 + pub use scopes::{ 9 + InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, 10 + intersect_scopes, validate_delegation_scopes, 11 + }; 12 pub use tranquil_db_traits::DelegationActionType;
+31 -11
crates/tranquil-pds/src/delegation/roles.rs
··· 13 user: &'a AuthenticatedUser, 14 } 15 16 impl<'a> CanAddControllers<'a> { 17 pub fn did(&self) -> &Did { 18 &self.user.did ··· 33 } 34 } 35 36 pub async fn verify_can_add_controllers<'a>( 37 state: &AppState, 38 user: &'a AuthenticatedUser, ··· 45 Ok(false) => Ok(CanAddControllers { user }), 46 Err(e) => { 47 tracing::error!("Failed to check delegation status: {:?}", e); 48 - Err(ApiError::InternalError(Some("Failed to verify delegation status".into())) 49 - .into_response()) 50 } 51 } 52 } ··· 63 Ok(false) => Ok(CanControlAccounts { user }), 64 Err(e) => { 65 tracing::error!("Failed to check controller status: {:?}", e); 66 - Err(ApiError::InternalError(Some("Failed to verify controller status".into())) 67 - .into_response()) 68 } 69 } 70 } 71 72 - pub async fn verify_can_be_controller( 73 state: &AppState, 74 - controller_did: &Did, 75 - ) -> Result<(), Response> { 76 - match state.delegation_repo.has_any_controllers(controller_did).await { 77 Ok(true) => Err(ApiError::InvalidDelegation( 78 "Cannot add a controlled account as a controller".into(), 79 ) 80 .into_response()), 81 - Ok(false) => Ok(()), 82 Err(e) => { 83 tracing::error!("Failed to check controller status: {:?}", e); 84 - Err(ApiError::InternalError(Some("Failed to verify controller status".into())) 85 - .into_response()) 86 } 87 } 88 }
··· 13 user: &'a AuthenticatedUser, 14 } 15 16 + pub struct CanBeController<'a> { 17 + controller_did: &'a Did, 18 + } 19 + 20 impl<'a> CanAddControllers<'a> { 21 pub fn did(&self) -> &Did { 22 &self.user.did ··· 37 } 38 } 39 40 + impl<'a> CanBeController<'a> { 41 + pub fn did(&self) -> &Did { 42 + self.controller_did 43 + } 44 + } 45 + 46 pub async fn verify_can_add_controllers<'a>( 47 state: &AppState, 48 user: &'a AuthenticatedUser, ··· 55 Ok(false) => Ok(CanAddControllers { user }), 56 Err(e) => { 57 tracing::error!("Failed to check delegation status: {:?}", e); 58 + Err( 59 + ApiError::InternalError(Some("Failed to verify delegation status".into())) 60 + .into_response(), 61 + ) 62 } 63 } 64 } ··· 75 Ok(false) => Ok(CanControlAccounts { user }), 76 Err(e) => { 77 tracing::error!("Failed to check controller status: {:?}", e); 78 + Err( 79 + ApiError::InternalError(Some("Failed to verify controller status".into())) 80 + .into_response(), 81 + ) 82 } 83 } 84 } 85 86 + pub async fn verify_can_be_controller<'a>( 87 state: &AppState, 88 + controller_did: &'a Did, 89 + ) -> Result<CanBeController<'a>, Response> { 90 + match state 91 + .delegation_repo 92 + .has_any_controllers(controller_did) 93 + .await 94 + { 95 Ok(true) => Err(ApiError::InvalidDelegation( 96 "Cannot add a controlled account as a controller".into(), 97 ) 98 .into_response()), 99 + Ok(false) => Ok(CanBeController { controller_did }), 100 Err(e) => { 101 tracing::error!("Failed to check controller status: {:?}", e); 102 + Err( 103 + ApiError::InternalError(Some("Failed to verify controller status".into())) 104 + .into_response(), 105 + ) 106 } 107 } 108 }
+25 -82
crates/tranquil-pds/src/oauth/endpoints/authorize.rs
··· 1 use crate::comms::{channel_display_name, comms_repo::enqueue_2fa_code}; 2 use crate::oauth::{ 3 AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId, ··· 252 if let Some(ref login_hint) = request_data.parameters.login_hint { 253 tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation"); 254 let hostname_for_handles = pds_hostname_without_port(); 255 - let normalized = if login_hint.contains('@') || login_hint.starts_with("did:") { 256 - login_hint.clone() 257 - } else if !login_hint.contains('.') { 258 - format!("{}.{}", login_hint.to_lowercase(), hostname_for_handles) 259 - } else { 260 - login_hint.to_lowercase() 261 - }; 262 tracing::info!(normalized = %normalized, "Normalized login_hint"); 263 264 match state 265 .user_repo 266 - .get_login_check_by_handle_or_email(&normalized) 267 .await 268 { 269 Ok(Some(user)) => { ··· 532 )) 533 }; 534 let hostname_for_handles = pds_hostname_without_port(); 535 - let normalized_username = form.username.trim(); 536 - let normalized_username = normalized_username 537 - .strip_prefix('@') 538 - .unwrap_or(normalized_username); 539 - let normalized_username = if normalized_username.contains('@') { 540 - normalized_username.to_string() 541 - } else if !normalized_username.contains('.') { 542 - format!("{}.{}", normalized_username, hostname_for_handles) 543 - } else { 544 - normalized_username.to_string() 545 - }; 546 tracing::debug!( 547 original_username = %form.username, 548 normalized_username = %normalized_username, ··· 551 ); 552 let user = match state 553 .user_repo 554 - .get_login_info_by_handle_or_email(&normalized_username) 555 .await 556 { 557 Ok(Some(u)) => u, ··· 570 if user.takedown_ref.is_some() { 571 return show_login_error("This account has been taken down.", json_response); 572 } 573 - let is_verified = user.email_verified 574 - || user.discord_verified 575 - || user.telegram_verified 576 - || user.signal_verified; 577 if !is_verified { 578 return show_login_error( 579 "Please verify your account before logging in.", ··· 581 ); 582 } 583 584 - if user.account_type == "delegated" { 585 if state 586 .oauth_repo 587 .set_authorization_did(&form_request_id, &user.did, None) ··· 971 ); 972 } 973 }; 974 - let is_verified = user.email_verified 975 - || user.discord_verified 976 - || user.telegram_verified 977 - || user.signal_verified; 978 if !is_verified { 979 return json_error( 980 StatusCode::FORBIDDEN, ··· 1407 Ok(flow) => match flow.require_user() { 1408 Ok(u) => u, 1409 Err(_) => { 1410 - return json_error( 1411 - StatusCode::FORBIDDEN, 1412 - "access_denied", 1413 - "Not authenticated", 1414 - ); 1415 } 1416 }, 1417 Err(_) => { ··· 1456 }; 1457 1458 let effective_scope_str = if let Some(ref grant) = delegation_grant { 1459 - crate::delegation::intersect_scopes(requested_scope_str, &grant.granted_scopes) 1460 } else { 1461 requested_scope_str.to_string() 1462 }; ··· 1555 let level = if let Some(ref grant) = delegation_grant { 1556 let preset = crate::delegation::SCOPE_PRESETS 1557 .iter() 1558 - .find(|p| p.scopes == grant.granted_scopes); 1559 preset 1560 .map(|p| p.label.to_string()) 1561 .unwrap_or_else(|| "Custom".to_string()) ··· 1665 }; 1666 1667 let effective_scope_str = if let Some(ref grant) = delegation_grant { 1668 - crate::delegation::intersect_scopes(original_scope_str, &grant.granted_scopes) 1669 } else { 1670 original_scope_str.to_string() 1671 }; ··· 1925 StatusCode::TOO_MANY_REQUESTS, 1926 "RateLimitExceeded", 1927 "Too many verification attempts. Please try again in a few minutes.", 1928 - ) 1929 } 1930 }; 1931 let totp_valid = ··· 2017 Query(query): Query<CheckPasskeysQuery>, 2018 ) -> Response { 2019 let hostname_for_handles = pds_hostname_without_port(); 2020 - let normalized_identifier = query.identifier.trim(); 2021 - let normalized_identifier = normalized_identifier 2022 - .strip_prefix('@') 2023 - .unwrap_or(normalized_identifier); 2024 - let normalized_identifier = if let Some(bare_handle) = 2025 - normalized_identifier.strip_suffix(&format!(".{}", hostname_for_handles)) 2026 - { 2027 - bare_handle.to_string() 2028 - } else { 2029 - normalized_identifier.to_string() 2030 - }; 2031 2032 let user = state 2033 .user_repo 2034 - .get_login_check_by_handle_or_email(&normalized_identifier) 2035 .await; 2036 2037 let has_passkeys = match user { ··· 2058 Query(query): Query<CheckPasskeysQuery>, 2059 ) -> Response { 2060 let hostname_for_handles = pds_hostname_without_port(); 2061 - let identifier = query.identifier.trim(); 2062 - let identifier = identifier.strip_prefix('@').unwrap_or(identifier); 2063 - let normalized_identifier = if identifier.contains('@') || identifier.starts_with("did:") { 2064 - identifier.to_string() 2065 - } else if !identifier.contains('.') { 2066 - format!("{}.{}", identifier.to_lowercase(), hostname_for_handles) 2067 - } else { 2068 - identifier.to_lowercase() 2069 - }; 2070 2071 let user = state 2072 .user_repo 2073 - .get_login_check_by_handle_or_email(&normalized_identifier) 2074 .await; 2075 2076 let (has_passkeys, has_totp, has_password, is_delegated, did): ( ··· 2173 } 2174 2175 let hostname_for_handles = pds_hostname_without_port(); 2176 - let normalized_username = form.identifier.trim(); 2177 - let normalized_username = normalized_username 2178 - .strip_prefix('@') 2179 - .unwrap_or(normalized_username); 2180 - let normalized_username = if normalized_username.contains('@') { 2181 - normalized_username.to_string() 2182 - } else if !normalized_username.contains('.') { 2183 - format!("{}.{}", normalized_username, hostname_for_handles) 2184 - } else { 2185 - normalized_username.to_string() 2186 - }; 2187 2188 let user = match state 2189 .user_repo 2190 - .get_login_info_by_handle_or_email(&normalized_username) 2191 .await 2192 { 2193 Ok(Some(u)) => u, ··· 2235 .into_response(); 2236 } 2237 2238 - let is_verified = user.email_verified 2239 - || user.discord_verified 2240 - || user.telegram_verified 2241 - || user.signal_verified; 2242 2243 if !is_verified { 2244 return ( ··· 3345 } 3346 3347 let is_verified = match state.user_repo.get_session_info_by_did(&did).await { 3348 - Ok(Some(info)) => { 3349 - info.email_verified 3350 - || info.discord_verified 3351 - || info.telegram_verified 3352 - || info.signal_verified 3353 - } 3354 Ok(None) => { 3355 return ( 3356 StatusCode::FORBIDDEN,
··· 1 + use crate::auth::{BareLoginIdentifier, NormalizedLoginIdentifier}; 2 use crate::comms::{channel_display_name, comms_repo::enqueue_2fa_code}; 3 use crate::oauth::{ 4 AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId, ··· 253 if let Some(ref login_hint) = request_data.parameters.login_hint { 254 tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation"); 255 let hostname_for_handles = pds_hostname_without_port(); 256 + let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles); 257 tracing::info!(normalized = %normalized, "Normalized login_hint"); 258 259 match state 260 .user_repo 261 + .get_login_check_by_handle_or_email(normalized.as_str()) 262 .await 263 { 264 Ok(Some(user)) => { ··· 527 )) 528 }; 529 let hostname_for_handles = pds_hostname_without_port(); 530 + let normalized_username = 531 + NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles); 532 tracing::debug!( 533 original_username = %form.username, 534 normalized_username = %normalized_username, ··· 537 ); 538 let user = match state 539 .user_repo 540 + .get_login_info_by_handle_or_email(normalized_username.as_str()) 541 .await 542 { 543 Ok(Some(u)) => u, ··· 556 if user.takedown_ref.is_some() { 557 return show_login_error("This account has been taken down.", json_response); 558 } 559 + let is_verified = user.channel_verification.has_any_verified(); 560 if !is_verified { 561 return show_login_error( 562 "Please verify your account before logging in.", ··· 564 ); 565 } 566 567 + if user.account_type.is_delegated() { 568 if state 569 .oauth_repo 570 .set_authorization_did(&form_request_id, &user.did, None) ··· 954 ); 955 } 956 }; 957 + let is_verified = user.channel_verification.has_any_verified(); 958 if !is_verified { 959 return json_error( 960 StatusCode::FORBIDDEN, ··· 1387 Ok(flow) => match flow.require_user() { 1388 Ok(u) => u, 1389 Err(_) => { 1390 + return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated"); 1391 } 1392 }, 1393 Err(_) => { ··· 1432 }; 1433 1434 let effective_scope_str = if let Some(ref grant) = delegation_grant { 1435 + crate::delegation::intersect_scopes(requested_scope_str, grant.granted_scopes.as_str()) 1436 } else { 1437 requested_scope_str.to_string() 1438 }; ··· 1531 let level = if let Some(ref grant) = delegation_grant { 1532 let preset = crate::delegation::SCOPE_PRESETS 1533 .iter() 1534 + .find(|p| p.scopes == grant.granted_scopes.as_str()); 1535 preset 1536 .map(|p| p.label.to_string()) 1537 .unwrap_or_else(|| "Custom".to_string()) ··· 1641 }; 1642 1643 let effective_scope_str = if let Some(ref grant) = delegation_grant { 1644 + crate::delegation::intersect_scopes(original_scope_str, grant.granted_scopes.as_str()) 1645 } else { 1646 original_scope_str.to_string() 1647 }; ··· 1901 StatusCode::TOO_MANY_REQUESTS, 1902 "RateLimitExceeded", 1903 "Too many verification attempts. Please try again in a few minutes.", 1904 + ); 1905 } 1906 }; 1907 let totp_valid = ··· 1993 Query(query): Query<CheckPasskeysQuery>, 1994 ) -> Response { 1995 let hostname_for_handles = pds_hostname_without_port(); 1996 + let bare_identifier = 1997 + BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles); 1998 1999 let user = state 2000 .user_repo 2001 + .get_login_check_by_handle_or_email(bare_identifier.as_str()) 2002 .await; 2003 2004 let has_passkeys = match user { ··· 2025 Query(query): Query<CheckPasskeysQuery>, 2026 ) -> Response { 2027 let hostname_for_handles = pds_hostname_without_port(); 2028 + let normalized_identifier = 2029 + NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles); 2030 2031 let user = state 2032 .user_repo 2033 + .get_login_check_by_handle_or_email(normalized_identifier.as_str()) 2034 .await; 2035 2036 let (has_passkeys, has_totp, has_password, is_delegated, did): ( ··· 2133 } 2134 2135 let hostname_for_handles = pds_hostname_without_port(); 2136 + let normalized_username = 2137 + NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles); 2138 2139 let user = match state 2140 .user_repo 2141 + .get_login_info_by_handle_or_email(normalized_username.as_str()) 2142 .await 2143 { 2144 Ok(Some(u)) => u, ··· 2186 .into_response(); 2187 } 2188 2189 + let is_verified = user.channel_verification.has_any_verified(); 2190 2191 if !is_verified { 2192 return ( ··· 3293 } 3294 3295 let is_verified = match state.user_repo.get_session_info_by_did(&did).await { 3296 + Ok(Some(info)) => info.channel_verification.has_any_verified(), 3297 Ok(None) => { 3298 return ( 3299 StatusCode::FORBIDDEN,
crates/tranquil-pds/src/oauth/endpoints/delegation.rs

This file has not been changed.

crates/tranquil-pds/src/oauth/endpoints/metadata.rs

This file has not been changed.

+2 -1
crates/tranquil-pds/src/oauth/endpoints/par.rs
··· 78 .as_ref() 79 .filter(|s| !s.is_empty()) 80 .ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?; 81 - let code_challenge_method = parse_code_challenge_method(request.code_challenge_method.as_deref())?; 82 let client_cache = ClientMetadataCache::new(3600); 83 let client_metadata = client_cache.get(&request.client_id).await?; 84 client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
··· 78 .as_ref() 79 .filter(|s| !s.is_empty()) 80 .ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?; 81 + let code_challenge_method = 82 + parse_code_challenge_method(request.code_challenge_method.as_deref())?; 83 let client_cache = ClientMetadataCache::new(3600); 84 let client_metadata = client_cache.get(&request.client_id).await?; 85 client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
+9 -16
crates/tranquil-pds/src/oauth/endpoints/token/grants.rs
··· 52 .map_err(crate::oauth::db_err_to_oauth)? 53 .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; 54 55 - let flow = AuthFlow::from_request_data(auth_request).map_err(|_| { 56 - OAuthError::InvalidGrant("Authorization code has expired".to_string()) 57 - })?; 58 59 - let authorized = flow.require_authorized().map_err(|_| { 60 - OAuthError::InvalidGrant("Authorization not completed".to_string()) 61 - })?; 62 63 if let Some(request_client_id) = &request.client_auth.client_id 64 && request_client_id != &authorized.client_id ··· 99 let dpop_jkt = if let Some(proof) = &dpop_proof { 100 let config = AuthConfig::get(); 101 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 102 - let pds_hostname = 103 - pds_hostname(); 104 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 105 let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; 106 if !state ··· 146 .ok() 147 .flatten(); 148 let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 149 - let requested = authorized 150 - .parameters 151 - .scope 152 - .as_deref() 153 - .unwrap_or("atproto"); 154 - let intersected = intersect_scopes(requested, &granted_scopes); 155 (Some(intersected), Some(controller.clone())) 156 } else { 157 (authorized.parameters.scope.clone(), None) ··· 337 let dpop_jkt = if let Some(proof) = &dpop_proof { 338 let config = AuthConfig::get(); 339 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 340 - let pds_hostname = 341 - pds_hostname(); 342 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 343 let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; 344 if !state
··· 52 .map_err(crate::oauth::db_err_to_oauth)? 53 .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; 54 55 + let flow = AuthFlow::from_request_data(auth_request) 56 + .map_err(|_| OAuthError::InvalidGrant("Authorization code has expired".to_string()))?; 57 58 + let authorized = flow 59 + .require_authorized() 60 + .map_err(|_| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; 61 62 if let Some(request_client_id) = &request.client_auth.client_id 63 && request_client_id != &authorized.client_id ··· 98 let dpop_jkt = if let Some(proof) = &dpop_proof { 99 let config = AuthConfig::get(); 100 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 101 + let pds_hostname = pds_hostname(); 102 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 103 let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; 104 if !state ··· 144 .ok() 145 .flatten(); 146 let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 147 + let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto"); 148 + let intersected = intersect_scopes(requested, granted_scopes.as_str()); 149 (Some(intersected), Some(controller.clone())) 150 } else { 151 (authorized.parameters.scope.clone(), None) ··· 331 let dpop_jkt = if let Some(proof) = &dpop_proof { 332 let config = AuthConfig::get(); 333 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 334 + let pds_hostname = pds_hostname(); 335 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 336 let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; 337 if !state
crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs

This file has not been changed.

crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs

This file has not been changed.

crates/tranquil-pds/src/oauth/endpoints/token/mod.rs

This file has not been changed.

crates/tranquil-pds/src/oauth/mod.rs

This file has not been changed.

+3 -1
crates/tranquil-pds/src/oauth/verify.rs
··· 186 .and_then(|s| s.as_str()) 187 .map(|s| s.parse::<Did>()) 188 .transpose() 189 - .map_err(|_| OAuthError::InvalidToken("Invalid act.sub claim (not a valid DID)".to_string()))?; 190 Ok(OAuthTokenInfo { 191 did, 192 token_id,
··· 186 .and_then(|s| s.as_str()) 187 .map(|s| s.parse::<Did>()) 188 .transpose() 189 + .map_err(|_| { 190 + OAuthError::InvalidToken("Invalid act.sub claim (not a valid DID)".to_string()) 191 + })?; 192 Ok(OAuthTokenInfo { 193 did, 194 token_id,
crates/tranquil-pds/src/rate_limit/extractor.rs

This file has not been changed.

crates/tranquil-pds/src/rate_limit/mod.rs

This file has not been changed.

crates/tranquil-pds/src/sso/config.rs

This file has not been changed.

+44 -47
crates/tranquil-pds/src/sso/endpoints.rs
··· 6 }; 7 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 8 use serde::{Deserialize, Serialize}; 9 - use tranquil_db_traits::SsoProviderType; 10 use tranquil_types::RequestId; 11 12 use super::config::SsoConfig; ··· 101 .get_provider(provider_type) 102 .ok_or(ApiError::SsoProviderNotEnabled)?; 103 104 - let action = input.action.as_deref().unwrap_or("login"); 105 - if !["login", "link", "register"].contains(&action) { 106 - return Err(ApiError::SsoInvalidAction); 107 - } 108 109 - let is_standalone = action == "register" && input.request_uri.is_none(); 110 let request_uri = input 111 .request_uri 112 .clone() 113 .unwrap_or_else(|| "standalone".to_string()); 114 115 let auth_did = match action { 116 - "link" => { 117 let auth_header = headers 118 .get(axum::http::header::AUTHORIZATION) 119 .and_then(|v| v.to_str().ok()); ··· 128 .map_err(|_| ApiError::SsoNotAuthenticated)?; 129 Some(auth_user.did) 130 } 131 - "register" if is_standalone => None, 132 _ => { 133 let request_id = RequestId::new(request_uri.clone()); 134 let _request_data = state ··· 317 } 318 }; 319 320 - match auth_state.action.as_str() { 321 - "login" => { 322 handle_sso_login( 323 state, 324 &auth_state.request_uri, ··· 327 ) 328 .await 329 } 330 - "link" => { 331 let did = match auth_state.did { 332 Some(d) => d, 333 None => return redirect_to_error("Not authenticated"), 334 }; 335 handle_sso_link(state, did, auth_state.provider, &user_info).await 336 } 337 - "register" => { 338 handle_sso_register( 339 state, 340 &auth_state.request_uri, ··· 343 ) 344 .await 345 } 346 - _ => redirect_to_error("Unknown SSO action"), 347 } 348 } 349 ··· 420 }; 421 422 let is_verified = match state.user_repo.get_session_info_by_did(&identity.did).await { 423 - Ok(Some(info)) => { 424 - info.email_verified 425 - || info.discord_verified 426 - || info.telegram_verified 427 - || info.signal_verified 428 - } 429 Ok(None) => { 430 tracing::error!("User not found for SSO login: {}", identity.did); 431 return redirect_to_error("Account not found"); ··· 477 "SSO login successful" 478 ); 479 480 - let has_totp = match state.user_repo.get_totp_record(&identity.did).await { 481 - Ok(Some(record)) => record.verified, 482 - _ => false, 483 - }; 484 485 if has_totp { 486 return Redirect::to(&format!( ··· 648 id: id.id.to_string(), 649 provider: id.provider.as_str().to_string(), 650 provider_name: id.provider.display_name().to_string(), 651 - provider_username: id.provider_username, 652 - provider_email: id.provider_email, 653 created_at: id.created_at.to_rfc3339(), 654 last_login_at: id.last_login_at.map(|t| t.to_rfc3339()), 655 }) ··· 752 Ok(Json(PendingRegistrationResponse { 753 request_uri: pending.request_uri, 754 provider: pending.provider.as_str().to_string(), 755 - provider_user_id: pending.provider_user_id, 756 - provider_username: pending.provider_username, 757 - provider_email: pending.provider_email, 758 provider_email_verified: pending.provider_email_verified, 759 })) 760 } ··· 885 let email = input 886 .email 887 .clone() 888 - .or_else(|| pending_preview.provider_email.clone()) 889 .map(|e| e.trim().to_string()) 890 .filter(|e| !e.is_empty()); 891 match email { ··· 911 let email = input 912 .email 913 .clone() 914 - .or_else(|| pending_preview.provider_email.clone()) 915 .map(|e| e.trim().to_string()) 916 .filter(|e| !e.is_empty()); 917 ··· 928 None => None, 929 }; 930 931 - if let Some(ref code) = input.invite_code { 932 - let valid = state 933 - .infra_repo 934 - .is_invite_code_valid(code) 935 - .await 936 - .unwrap_or(false); 937 - if !valid { 938 - return Err(ApiError::InvalidInviteCode); 939 } 940 } else { 941 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 944 if invite_required { 945 return Err(ApiError::InviteCodeRequired); 946 } 947 - } 948 949 let handle_typed = crate::types::Handle::new_unchecked(&handle); 950 let reserved = state ··· 1116 invite_code: input.invite_code.clone(), 1117 birthdate_pref, 1118 sso_provider: pending_preview.provider, 1119 - sso_provider_user_id: pending_preview.provider_user_id.clone(), 1120 - sso_provider_username: pending_preview.provider_username.clone(), 1121 - sso_provider_email: pending_preview.provider_email.clone(), 1122 sso_provider_email_verified: pending_preview.provider_email_verified, 1123 pending_registration_token: input.token.clone(), 1124 }; ··· 1151 { 1152 tracing::warn!("Failed to sequence identity event for {}: {}", did, e); 1153 } 1154 - if let Err(e) = 1155 - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await 1156 { 1157 tracing::warn!("Failed to sequence account event for {}: {}", did, e); 1158 } ··· 1189 user_id: create_result.user_id, 1190 name: app_password_name.clone(), 1191 password_hash: app_password_hash, 1192 - privileged: false, 1193 scopes: None, 1194 created_by_controller_did: None, 1195 }; ··· 1232 1233 let channel_auto_verified = verification_channel == "email" 1234 && pending_preview.provider_email_verified 1235 - && pending_preview.provider_email.as_ref() == email.as_ref(); 1236 1237 if channel_auto_verified { 1238 let _ = state ··· 1276 refresh_jti: refresh_meta.jti.clone(), 1277 access_expires_at: access_meta.expires_at, 1278 refresh_expires_at: refresh_meta.expires_at, 1279 - legacy_login: false, 1280 mfa_verified: false, 1281 scope: None, 1282 controller_did: None,
··· 6 }; 7 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 8 use serde::{Deserialize, Serialize}; 9 + use tranquil_db_traits::{SsoAction, SsoProviderType}; 10 use tranquil_types::RequestId; 11 12 use super::config::SsoConfig; ··· 101 .get_provider(provider_type) 102 .ok_or(ApiError::SsoProviderNotEnabled)?; 103 104 + let action = input 105 + .action 106 + .as_deref() 107 + .map(SsoAction::parse) 108 + .unwrap_or(Some(SsoAction::Login)) 109 + .ok_or(ApiError::SsoInvalidAction)?; 110 111 + let is_standalone = action == SsoAction::Register && input.request_uri.is_none(); 112 let request_uri = input 113 .request_uri 114 .clone() 115 .unwrap_or_else(|| "standalone".to_string()); 116 117 let auth_did = match action { 118 + SsoAction::Link => { 119 let auth_header = headers 120 .get(axum::http::header::AUTHORIZATION) 121 .and_then(|v| v.to_str().ok()); ··· 130 .map_err(|_| ApiError::SsoNotAuthenticated)?; 131 Some(auth_user.did) 132 } 133 + SsoAction::Register if is_standalone => None, 134 _ => { 135 let request_id = RequestId::new(request_uri.clone()); 136 let _request_data = state ··· 319 } 320 }; 321 322 + match auth_state.action { 323 + SsoAction::Login => { 324 handle_sso_login( 325 state, 326 &auth_state.request_uri, ··· 329 ) 330 .await 331 } 332 + SsoAction::Link => { 333 let did = match auth_state.did { 334 Some(d) => d, 335 None => return redirect_to_error("Not authenticated"), 336 }; 337 handle_sso_link(state, did, auth_state.provider, &user_info).await 338 } 339 + SsoAction::Register => { 340 handle_sso_register( 341 state, 342 &auth_state.request_uri, ··· 345 ) 346 .await 347 } 348 } 349 } 350 ··· 421 }; 422 423 let is_verified = match state.user_repo.get_session_info_by_did(&identity.did).await { 424 + Ok(Some(info)) => info.channel_verification.has_any_verified(), 425 Ok(None) => { 426 tracing::error!("User not found for SSO login: {}", identity.did); 427 return redirect_to_error("Account not found"); ··· 473 "SSO login successful" 474 ); 475 476 + let has_totp = matches!( 477 + state.user_repo.get_totp_record_state(&identity.did).await, 478 + Ok(Some(tranquil_db_traits::TotpRecordState::Verified(_))) 479 + ); 480 481 if has_totp { 482 return Redirect::to(&format!( ··· 644 id: id.id.to_string(), 645 provider: id.provider.as_str().to_string(), 646 provider_name: id.provider.display_name().to_string(), 647 + provider_username: id.provider_username.map(|u| u.into_inner()), 648 + provider_email: id.provider_email.map(|e| e.into_inner()), 649 created_at: id.created_at.to_rfc3339(), 650 last_login_at: id.last_login_at.map(|t| t.to_rfc3339()), 651 }) ··· 748 Ok(Json(PendingRegistrationResponse { 749 request_uri: pending.request_uri, 750 provider: pending.provider.as_str().to_string(), 751 + provider_user_id: pending.provider_user_id.into_inner(), 752 + provider_username: pending.provider_username.map(|u| u.into_inner()), 753 + provider_email: pending.provider_email.map(|e| e.into_inner()), 754 provider_email_verified: pending.provider_email_verified, 755 })) 756 } ··· 881 let email = input 882 .email 883 .clone() 884 + .or_else(|| pending_preview.provider_email.clone().map(|e| e.into_inner())) 885 .map(|e| e.trim().to_string()) 886 .filter(|e| !e.is_empty()); 887 match email { ··· 907 let email = input 908 .email 909 .clone() 910 + .or_else(|| pending_preview.provider_email.clone().map(|e| e.into_inner())) 911 .map(|e| e.trim().to_string()) 912 .filter(|e| !e.is_empty()); 913 ··· 924 None => None, 925 }; 926 927 + let _validated_invite_code = if let Some(ref code) = input.invite_code { 928 + match state.infra_repo.validate_invite_code(code).await { 929 + Ok(validated) => Some(validated), 930 + Err(_) => return Err(ApiError::InvalidInviteCode), 931 } 932 } else { 933 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 936 if invite_required { 937 return Err(ApiError::InviteCodeRequired); 938 } 939 + None 940 + }; 941 942 let handle_typed = crate::types::Handle::new_unchecked(&handle); 943 let reserved = state ··· 1109 invite_code: input.invite_code.clone(), 1110 birthdate_pref, 1111 sso_provider: pending_preview.provider, 1112 + sso_provider_user_id: pending_preview.provider_user_id.clone().into_inner(), 1113 + sso_provider_username: pending_preview.provider_username.clone().map(|u| u.into_inner()), 1114 + sso_provider_email: pending_preview.provider_email.clone().map(|e| e.into_inner()), 1115 sso_provider_email_verified: pending_preview.provider_email_verified, 1116 pending_registration_token: input.token.clone(), 1117 }; ··· 1144 { 1145 tracing::warn!("Failed to sequence identity event for {}: {}", did, e); 1146 } 1147 + if let Err(e) = crate::api::repo::record::sequence_account_event( 1148 + &state, 1149 + &did_typed, 1150 + tranquil_db_traits::AccountStatus::Active, 1151 + ) 1152 + .await 1153 { 1154 tracing::warn!("Failed to sequence account event for {}: {}", did, e); 1155 } ··· 1186 user_id: create_result.user_id, 1187 name: app_password_name.clone(), 1188 password_hash: app_password_hash, 1189 + privilege: tranquil_db_traits::AppPasswordPrivilege::Standard, 1190 scopes: None, 1191 created_by_controller_did: None, 1192 }; ··· 1229 1230 let channel_auto_verified = verification_channel == "email" 1231 && pending_preview.provider_email_verified 1232 + && pending_preview.provider_email.as_ref().map(|e| e.as_str()) == email.as_deref(); 1233 1234 if channel_auto_verified { 1235 let _ = state ··· 1273 refresh_jti: refresh_meta.jti.clone(), 1274 access_expires_at: access_meta.expires_at, 1275 refresh_expires_at: refresh_meta.expires_at, 1276 + login_type: tranquil_db_traits::LoginType::Modern, 1277 mfa_verified: false, 1278 scope: None, 1279 controller_did: None,
crates/tranquil-pds/src/state.rs

This file has not been changed.

+4 -3
crates/tranquil-pds/src/sync/deprecated.rs
··· 19 const MAX_REPO_BLOCKS_TRAVERSAL: usize = 20_000; 20 21 async fn check_admin_or_self(state: &AppState, headers: &HeaderMap, did: &str) -> bool { 22 - let extracted = match crate::auth::extract_auth_token_from_header( 23 - crate::util::get_header_str(headers, "Authorization"), 24 - ) { 25 Some(t) => t, 26 None => return false, 27 };
··· 19 const MAX_REPO_BLOCKS_TRAVERSAL: usize = 20_000; 20 21 async fn check_admin_or_self(state: &AppState, headers: &HeaderMap, did: &str) -> bool { 22 + let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str( 23 + headers, 24 + "Authorization", 25 + )) { 26 Some(t) => t, 27 None => return false, 28 };
+2 -3
crates/tranquil-pds/src/util.rs
··· 93 } 94 95 pub fn pds_hostname() -> &'static str { 96 - PDS_HOSTNAME.get_or_init(|| { 97 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 98 - }) 99 } 100 101 pub fn pds_hostname_without_port() -> &'static str {
··· 93 } 94 95 pub fn pds_hostname() -> &'static str { 96 + PDS_HOSTNAME 97 + .get_or_init(|| std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())) 98 } 99 100 pub fn pds_hostname_without_port() -> &'static str {
+27
.sqlx/query-03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET preferred_comms_channel = $1, updated_at = NOW() WHERE did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + { 9 + "Custom": { 10 + "name": "comms_channel", 11 + "kind": { 12 + "Enum": [ 13 + "email", 14 + "discord", 15 + "telegram", 16 + "signal" 17 + ] 18 + } 19 + } 20 + }, 21 + "Text" 22 + ] 23 + }, 24 + "nullable": [] 25 + }, 26 + "hash": "03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb" 27 + }
-22
.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc" 22 - }
···
-15
.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET deactivated_at = $1 WHERE did = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Timestamptz", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107" 15 - }
···
+3 -3
.sqlx/query-0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 - "name": "event_type", 24 "type_info": "Text" 25 }, 26 { ··· 96 true 97 ] 98 }, 99 - "hash": "805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651" 100 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 + "name": "event_type: RepoEventType", 24 "type_info": "Text" 25 }, 26 { ··· 96 true 97 ] 98 }, 99 + "hash": "0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f" 100 }
-22
.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "body", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99" 22 - }
···
-22
.sqlx/query-0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT token FROM sso_pending_registration WHERE token = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1" 22 - }
···
-22
.sqlx/query-1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac" 22 - }
···
+28
.sqlx/query-200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT available_uses, COALESCE(disabled, false) as \"disabled!\" FROM invite_codes WHERE code = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "available_uses", 9 + "type_info": "Int4" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "disabled!", 14 + "type_info": "Bool" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + null 25 + ] 26 + }, 27 + "hash": "200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224" 28 + }
+17 -5
.sqlx/query-247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n email,\n preferred_comms_channel::text as \"preferred_channel!\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1", 4 "describe": { 5 "columns": [ 6 { ··· 10 }, 11 { 12 "ordinal": 1, 13 - "name": "preferred_channel!", 14 - "type_info": "Text" 15 }, 16 { 17 "ordinal": 2, ··· 51 }, 52 "nullable": [ 53 true, 54 - null, 55 true, 56 false, 57 true, ··· 60 false 61 ] 62 }, 63 - "hash": "426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded" 64 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1", 4 "describe": { 5 "columns": [ 6 { ··· 10 }, 11 { 12 "ordinal": 1, 13 + "name": "preferred_channel!: CommsChannel", 14 + "type_info": { 15 + "Custom": { 16 + "name": "comms_channel", 17 + "kind": { 18 + "Enum": [ 19 + "email", 20 + "discord", 21 + "telegram", 22 + "signal" 23 + ] 24 + } 25 + } 26 + } 27 }, 28 { 29 "ordinal": 2, ··· 63 }, 64 "nullable": [ 65 true, 66 + false, 67 true, 68 false, 69 true, ··· 72 false 73 ] 74 }, 75 + "hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43" 76 }
-28
.sqlx/query-24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "request_uri", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81" 28 - }
···
+5 -5
.sqlx/query-25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n comms_type as \"comms_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT $2\n ", 4 "describe": { 5 "columns": [ 6 { ··· 10 }, 11 { 12 "ordinal": 1, 13 - "name": "channel: String", 14 "type_info": { 15 "Custom": { 16 "name": "comms_channel", ··· 27 }, 28 { 29 "ordinal": 2, 30 - "name": "comms_type: String", 31 "type_info": { 32 "Custom": { 33 "name": "comms_type", ··· 52 }, 53 { 54 "ordinal": 3, 55 - "name": "status: String", 56 "type_info": { 57 "Custom": { 58 "name": "comms_status", ··· 93 false 94 ] 95 }, 96 - "hash": "9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671" 97 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "\n SELECT\n created_at,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT $2\n ", 4 "describe": { 5 "columns": [ 6 { ··· 10 }, 11 { 12 "ordinal": 1, 13 + "name": "channel: CommsChannel", 14 "type_info": { 15 "Custom": { 16 "name": "comms_channel", ··· 27 }, 28 { 29 "ordinal": 2, 30 + "name": "comms_type: CommsType", 31 "type_info": { 32 "Custom": { 33 "name": "comms_type", ··· 52 }, 53 { 54 "ordinal": 3, 55 + "name": "status: CommsStatus", 56 "type_info": { 57 "Custom": { 58 "name": "comms_status", ··· 93 false 94 ] 95 }, 96 + "hash": "25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86" 97 }
-38
.sqlx/query-2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO external_identities (did, provider, provider_user_id)\n VALUES ($1, $2, $3)\n RETURNING id\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text", 15 - { 16 - "Custom": { 17 - "name": "sso_provider_type", 18 - "kind": { 19 - "Enum": [ 20 - "github", 21 - "discord", 22 - "google", 23 - "gitlab", 24 - "oidc", 25 - "apple" 26 - ] 27 - } 28 - } 29 - }, 30 - "Text" 31 - ] 32 - }, 33 - "nullable": [ 34 - false 35 - ] 36 - }, 37 - "hash": "2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c" 38 - }
···
-14
.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5" 14 - }
···
-22
.sqlx/query-36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as \"valid!\" FROM invite_codes WHERE code = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "valid!", 9 - "type_info": "Bool" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e" 22 - }
···
-32
.sqlx/query-376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc", 20 - "apple" 21 - ] 22 - } 23 - } 24 - }, 25 - "Text", 26 - "Bool" 27 - ] 28 - }, 29 - "nullable": [] 30 - }, 31 - "hash": "376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313" 32 - }
···
-22
.sqlx/query-3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id FROM external_identities WHERE id = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad" 22 - }
···
-16
.sqlx/query-3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - "Text" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12" 16 - }
···
-54
.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "subject", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "body", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "comms_type: String", 19 - "type_info": { 20 - "Custom": { 21 - "name": "comms_type", 22 - "kind": { 23 - "Enum": [ 24 - "welcome", 25 - "email_verification", 26 - "password_reset", 27 - "email_update", 28 - "account_deletion", 29 - "admin_email", 30 - "plc_operation", 31 - "two_factor_code", 32 - "channel_verification", 33 - "passkey_recovery", 34 - "legacy_login_alert", 35 - "migration_verification" 36 - ] 37 - } 38 - } 39 - } 40 - } 41 - ], 42 - "parameters": { 43 - "Left": [ 44 - "Uuid" 45 - ] 46 - }, 47 - "nullable": [ 48 - true, 49 - false, 50 - false 51 - ] 52 - }, 53 - "hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe" 54 - }
···
-22
.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id FROM users WHERE email = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068" 22 - }
···
-28
.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "expires_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88" 28 - }
···
-22
.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT email_verified FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email_verified", 9 - "type_info": "Bool" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6" 22 - }
···
-22
.sqlx/query-4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT token FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc" 22 - }
···
-15
.sqlx/query-575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at)\n VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour')\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Jsonb" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15" 15 - }
···
-33
.sqlx/query-596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc", 20 - "apple" 21 - ] 22 - } 23 - } 24 - }, 25 - "Text", 26 - "Text", 27 - "Text" 28 - ] 29 - }, 30 - "nullable": [] 31 - }, 32 - "hash": "596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e" 33 - }
···
-81
.sqlx/query-59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, did, provider as \"provider: SsoProviderType\", provider_user_id, provider_username, provider_email\n FROM external_identities\n WHERE provider = $1 AND provider_user_id = $2\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "provider: SsoProviderType", 19 - "type_info": { 20 - "Custom": { 21 - "name": "sso_provider_type", 22 - "kind": { 23 - "Enum": [ 24 - "github", 25 - "discord", 26 - "google", 27 - "gitlab", 28 - "oidc", 29 - "apple" 30 - ] 31 - } 32 - } 33 - } 34 - }, 35 - { 36 - "ordinal": 3, 37 - "name": "provider_user_id", 38 - "type_info": "Text" 39 - }, 40 - { 41 - "ordinal": 4, 42 - "name": "provider_username", 43 - "type_info": "Text" 44 - }, 45 - { 46 - "ordinal": 5, 47 - "name": "provider_email", 48 - "type_info": "Text" 49 - } 50 - ], 51 - "parameters": { 52 - "Left": [ 53 - { 54 - "Custom": { 55 - "name": "sso_provider_type", 56 - "kind": { 57 - "Enum": [ 58 - "github", 59 - "discord", 60 - "google", 61 - "gitlab", 62 - "oidc", 63 - "apple" 64 - ] 65 - } 66 - } 67 - }, 68 - "Text" 69 - ] 70 - }, 71 - "nullable": [ 72 - false, 73 - false, 74 - false, 75 - false, 76 - true, 77 - true 78 - ] 79 - }, 80 - "hash": "59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2" 81 - }
···
-28
.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT k.key_bytes, k.encryption_version\n FROM user_keys k\n JOIN users u ON k.user_id = u.id\n WHERE u.did = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "key_bytes", 9 - "type_info": "Bytea" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "encryption_version", 14 - "type_info": "Int4" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - true 25 - ] 26 - }, 27 - "hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0" 28 - }
···
-22
.sqlx/query-5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n DELETE FROM sso_auth_state\n WHERE state = $1 AND expires_at > NOW()\n RETURNING state\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "state", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1" 22 - }
···
-43
.sqlx/query-5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT provider_user_id, provider_email_verified\n FROM external_identities\n WHERE did = $1 AND provider = $2\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "provider_user_id", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "provider_email_verified", 14 - "type_info": "Bool" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text", 20 - { 21 - "Custom": { 22 - "name": "sso_provider_type", 23 - "kind": { 24 - "Enum": [ 25 - "github", 26 - "discord", 27 - "google", 28 - "gitlab", 29 - "oidc", 30 - "apple" 31 - ] 32 - } 33 - } 34 - } 35 - ] 36 - }, 37 - "nullable": [ 38 - false, 39 - false 40 - ] 41 - }, 42 - "hash": "5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06" 43 - }
···
-33
.sqlx/query-5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc", 20 - "apple" 21 - ] 22 - } 23 - } 24 - }, 25 - "Text", 26 - "Text", 27 - "Text" 28 - ] 29 - }, 30 - "nullable": [] 31 - }, 32 - "hash": "5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890" 33 - }
···
-28
.sqlx/query-63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT did, email_verified FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "email_verified", 14 - "type_info": "Bool" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e" 28 - }
···
-66
.sqlx/query-6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT state, request_uri, provider as \"provider: SsoProviderType\", action, nonce, code_verifier\n FROM sso_auth_state\n WHERE state = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "state", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "request_uri", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "provider: SsoProviderType", 19 - "type_info": { 20 - "Custom": { 21 - "name": "sso_provider_type", 22 - "kind": { 23 - "Enum": [ 24 - "github", 25 - "discord", 26 - "google", 27 - "gitlab", 28 - "oidc", 29 - "apple" 30 - ] 31 - } 32 - } 33 - } 34 - }, 35 - { 36 - "ordinal": 3, 37 - "name": "action", 38 - "type_info": "Text" 39 - }, 40 - { 41 - "ordinal": 4, 42 - "name": "nonce", 43 - "type_info": "Text" 44 - }, 45 - { 46 - "ordinal": 5, 47 - "name": "code_verifier", 48 - "type_info": "Text" 49 - } 50 - ], 51 - "parameters": { 52 - "Left": [ 53 - "Text" 54 - ] 55 - }, 56 - "nullable": [ 57 - false, 58 - false, 59 - false, 60 - false, 61 - true, 62 - true 63 - ] 64 - }, 65 - "hash": "6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee" 66 - }
···
-22
.sqlx/query-6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT state FROM sso_auth_state WHERE state = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "state", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba" 22 - }
···
+15 -5
.sqlx/query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type::text as \"account_type!\"\n FROM users\n WHERE handle = $1 OR email = $1\n ", 4 "describe": { 5 "columns": [ 6 { ··· 82 }, 83 { 84 "ordinal": 13, 85 - "name": "account_type!", 86 - "type_info": "Text" 87 } 88 ], 89 "parameters": { ··· 105 false, 106 false, 107 false, 108 - null 109 ] 110 }, 111 - "hash": "445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015" 112 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ", 4 "describe": { 5 "columns": [ 6 { ··· 82 }, 83 { 84 "ordinal": 13, 85 + "name": "account_type!: AccountType", 86 + "type_info": { 87 + "Custom": { 88 + "name": "account_type", 89 + "kind": { 90 + "Enum": [ 91 + "personal", 92 + "delegated" 93 + ] 94 + } 95 + } 96 + } 97 } 98 ], 99 "parameters": { ··· 115 false, 116 false, 117 false, 118 + false 119 ] 120 }, 121 + "hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa" 122 }
-33
.sqlx/query-712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc", 20 - "apple" 21 - ] 22 - } 23 - } 24 - }, 25 - "Text", 26 - "Text", 27 - "Bool" 28 - ] 29 - }, 30 - "nullable": [] 31 - }, 32 - "hash": "712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1" 33 - }
···
-22
.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "subject", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f" 22 - }
···
-22
.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT t.token\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631" 22 - }
···
-28
.sqlx/query-7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT provider_username, last_login_at FROM external_identities WHERE id = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "provider_username", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "last_login_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid" 20 - ] 21 - }, 22 - "nullable": [ 23 - true, 24 - true 25 - ] 26 - }, 27 - "hash": "7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0" 28 - }
···
-28
.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT t.token, t.expires_at FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "expires_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81" 28 - }
···
-34
.sqlx/query-85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc", 20 - "apple" 21 - ] 22 - } 23 - } 24 - }, 25 - "Text", 26 - "Text", 27 - "Text", 28 - "Bool" 29 - ] 30 - }, 31 - "nullable": [] 32 - }, 33 - "hash": "85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191" 34 - }
···
-22
.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "body", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5" 22 - }
···
-28
.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "public_key_did_key", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - true, 24 - false 25 - ] 26 - }, 27 - "hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002" 28 - }
···
-22
.sqlx/query-9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT id FROM external_identities WHERE did = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65" 22 - }
···
-66
.sqlx/query-9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\", provider_user_id,\n provider_username, provider_email\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "request_uri", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "provider: SsoProviderType", 19 - "type_info": { 20 - "Custom": { 21 - "name": "sso_provider_type", 22 - "kind": { 23 - "Enum": [ 24 - "github", 25 - "discord", 26 - "google", 27 - "gitlab", 28 - "oidc", 29 - "apple" 30 - ] 31 - } 32 - } 33 - } 34 - }, 35 - { 36 - "ordinal": 3, 37 - "name": "provider_user_id", 38 - "type_info": "Text" 39 - }, 40 - { 41 - "ordinal": 4, 42 - "name": "provider_username", 43 - "type_info": "Text" 44 - }, 45 - { 46 - "ordinal": 5, 47 - "name": "provider_email", 48 - "type_info": "Text" 49 - } 50 - ], 51 - "parameters": { 52 - "Left": [ 53 - "Text" 54 - ] 55 - }, 56 - "nullable": [ 57 - false, 58 - false, 59 - false, 60 - false, 61 - true, 62 - true 63 - ] 64 - }, 65 - "hash": "9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5" 66 - }
···
-34
.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "private_key_bytes", 9 - "type_info": "Bytea" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "expires_at", 14 - "type_info": "Timestamptz" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "used_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - true 31 - ] 32 - }, 33 - "hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5" 34 - }
···
-15
.sqlx/query-a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n UPDATE external_identities\n SET provider_username = $2, last_login_at = NOW()\n WHERE id = $1\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496" 15 - }
···
-22
.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT token FROM account_deletion_requests WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5" 22 - }
···
-28
.sqlx/query-aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n DELETE FROM sso_auth_state\n WHERE state = $1 AND expires_at > NOW()\n RETURNING state, request_uri\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "state", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "request_uri", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154" 28 - }
···
+3 -3
.sqlx/query-b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 - "name": "event_type", 24 "type_info": "Text" 25 }, 26 { ··· 97 true 98 ] 99 }, 100 - "hash": "caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db" 101 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 + "name": "event_type: RepoEventType", 24 "type_info": "Text" 25 }, 26 { ··· 97 true 98 ] 99 }, 100 + "hash": "b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b" 101 }
+3 -3
.sqlx/query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 - "name": "event_type", 24 "type_info": "Text" 25 }, 26 { ··· 96 true 97 ] 98 }, 99 - "hash": "e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23" 100 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 + "name": "event_type: RepoEventType", 24 "type_info": "Text" 25 }, 26 { ··· 96 true 97 ] 98 }, 99 + "hash": "b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903" 100 }
-31
.sqlx/query-ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, expires_at)\n VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour')\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc", 20 - "apple" 21 - ] 22 - } 23 - } 24 - }, 25 - "Text" 26 - ] 27 - }, 28 - "nullable": [] 29 - }, 30 - "hash": "ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb" 31 - }
···
-12
.sqlx/query-bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "DELETE FROM sso_auth_state WHERE expires_at < NOW()", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [] 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b" 12 - }
···
-22
.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT password_reset_code FROM users WHERE email = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "password_reset_code", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee" 22 - }
···
-22
.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "count!", 9 - "type_info": "Int8" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3" 22 - }
···
-31
.sqlx/query-d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_auth_state (state, request_uri, provider, action, expires_at)\n VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour')\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc", 20 - "apple" 21 - ] 22 - } 23 - } 24 - }, 25 - "Text" 26 - ] 27 - }, 28 - "nullable": [] 29 - }, 30 - "hash": "d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041" 31 - }
···
-14
.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4" 14 - }
···
+3 -3
.sqlx/query-d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 - "name": "event_type", 24 "type_info": "Text" 25 }, 26 { ··· 97 true 98 ] 99 }, 100 - "hash": "8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec" 101 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 + "name": "event_type: RepoEventType", 24 "type_info": "Text" 25 }, 26 { ··· 97 true 98 ] 99 }, 100 + "hash": "d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811" 101 }
+19 -7
.sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1", 4 "describe": { 5 "columns": [ 6 { 7 "ordinal": 0, 8 - "name": "preferred_comms_channel: String", 9 "type_info": { 10 "Custom": { 11 "name": "comms_channel", ··· 21 } 22 }, 23 { 24 - "ordinal": 1, 25 - "name": "discord_id", 26 - "type_info": "Text" 27 } 28 ], 29 "parameters": { 30 "Left": [ 31 - "Text" 32 ] 33 }, 34 "nullable": [ 35 false, 36 true 37 ] 38 }, 39 - "hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722" 40 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\n FROM users WHERE id = $1", 4 "describe": { 5 "columns": [ 6 { 7 "ordinal": 0, 8 + "name": "email", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "preferred_channel!: CommsChannel", 19 "type_info": { 20 "Custom": { 21 "name": "comms_channel", ··· 31 } 32 }, 33 { 34 + "ordinal": 3, 35 + "name": "preferred_locale", 36 + "type_info": "Varchar" 37 } 38 ], 39 "parameters": { 40 "Left": [ 41 + "Uuid" 42 ] 43 }, 44 "nullable": [ 45 + true, 46 + false, 47 false, 48 true 49 ] 50 }, 51 + "hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee" 52 }
-40
.sqlx/query-dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text", 15 - { 16 - "Custom": { 17 - "name": "sso_provider_type", 18 - "kind": { 19 - "Enum": [ 20 - "github", 21 - "discord", 22 - "google", 23 - "gitlab", 24 - "oidc", 25 - "apple" 26 - ] 27 - } 28 - } 29 - }, 30 - "Text", 31 - "Text", 32 - "Text" 33 - ] 34 - }, 35 - "nullable": [ 36 - false 37 - ] 38 - }, 39 - "hash": "dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0" 40 - }
···
-22
.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "count", 9 - "type_info": "Int8" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3" 22 - }
···
-40
.sqlx/query-e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT email, handle, preferred_comms_channel::text as \"preferred_channel!\", preferred_locale\n FROM users WHERE id = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "handle", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "preferred_channel!", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "preferred_locale", 24 - "type_info": "Varchar" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Uuid" 30 - ] 31 - }, 32 - "nullable": [ 33 - true, 34 - false, 35 - null, 36 - true 37 - ] 38 - }, 39 - "hash": "e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3" 40 - }
···
-22
.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "used_at", 9 - "type_info": "Timestamptz" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9" 22 - }
···
+3 -3
.sqlx/query-e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 - "name": "event_type", 24 "type_info": "Text" 25 }, 26 { ··· 97 true 98 ] 99 }, 100 - "hash": "605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc" 101 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", 4 "describe": { 5 "columns": [ 6 { ··· 20 }, 21 { 22 "ordinal": 3, 23 + "name": "event_type: RepoEventType", 24 "type_info": "Text" 25 }, 26 { ··· 97 true 98 ] 99 }, 100 + "hash": "e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a" 101 }
-30
.sqlx/query-eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO external_identities (did, provider, provider_user_id)\n VALUES ($1, $2, $3)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - { 10 - "Custom": { 11 - "name": "sso_provider_type", 12 - "kind": { 13 - "Enum": [ 14 - "github", 15 - "discord", 16 - "google", 17 - "gitlab", 18 - "oidc", 19 - "apple" 20 - ] 21 - } 22 - } 23 - }, 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [] 28 - }, 29 - "hash": "eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb" 30 - }
···
-15
.sqlx/query-ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "DELETE FROM external_identities WHERE id = $1 AND did = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d" 15 - }
···
-22
.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT email FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7" 22 - }
···
-14
.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET is_admin = TRUE WHERE did = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814" 14 - }
···
-28
.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "password_reset_code", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "password_reset_code_expires_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - true, 24 - true 25 - ] 26 - }, 27 - "hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382" 28 - }
···
+51
crates/tranquil-db-traits/src/channel_verification.rs
···
··· 1 + use crate::CommsChannel; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] 5 + pub struct ChannelVerificationStatus { 6 + pub email: bool, 7 + pub discord: bool, 8 + pub telegram: bool, 9 + pub signal: bool, 10 + } 11 + 12 + impl ChannelVerificationStatus { 13 + pub fn new(email: bool, discord: bool, telegram: bool, signal: bool) -> Self { 14 + Self { 15 + email, 16 + discord, 17 + telegram, 18 + signal, 19 + } 20 + } 21 + 22 + pub fn has_any_verified(&self) -> bool { 23 + self.email || self.discord || self.telegram || self.signal 24 + } 25 + 26 + pub fn verified_channels(&self) -> Vec<CommsChannel> { 27 + let mut channels = Vec::with_capacity(4); 28 + if self.email { 29 + channels.push(CommsChannel::Email); 30 + } 31 + if self.discord { 32 + channels.push(CommsChannel::Discord); 33 + } 34 + if self.telegram { 35 + channels.push(CommsChannel::Telegram); 36 + } 37 + if self.signal { 38 + channels.push(CommsChannel::Signal); 39 + } 40 + channels 41 + } 42 + 43 + pub fn is_verified(&self, channel: CommsChannel) -> bool { 44 + match channel { 45 + CommsChannel::Email => self.email, 46 + CommsChannel::Discord => self.discord, 47 + CommsChannel::Telegram => self.telegram, 48 + CommsChannel::Signal => self.signal, 49 + } 50 + } 51 + }
+6 -5
crates/tranquil-db-traits/src/delegation.rs
··· 5 use uuid::Uuid; 6 7 use crate::DbError; 8 9 #[derive(Debug, Clone, Serialize, Deserialize)] 10 pub struct DelegationGrant { 11 pub id: Uuid, 12 pub delegated_did: Did, 13 pub controller_did: Did, 14 - pub granted_scopes: String, 15 pub granted_at: DateTime<Utc>, 16 pub granted_by: Did, 17 pub revoked_at: Option<DateTime<Utc>>, ··· 22 pub struct DelegatedAccountInfo { 23 pub did: Did, 24 pub handle: Handle, 25 - pub granted_scopes: String, 26 pub granted_at: DateTime<Utc>, 27 } 28 ··· 30 pub struct ControllerInfo { 31 pub did: Did, 32 pub handle: Handle, 33 - pub granted_scopes: String, 34 pub granted_at: DateTime<Utc>, 35 pub is_active: bool, 36 } ··· 67 &self, 68 delegated_did: &Did, 69 controller_did: &Did, 70 - granted_scopes: &str, 71 granted_by: &Did, 72 ) -> Result<Uuid, DbError>; 73 ··· 82 &self, 83 delegated_did: &Did, 84 controller_did: &Did, 85 - new_scopes: &str, 86 ) -> Result<bool, DbError>; 87 88 async fn get_delegation(
··· 5 use uuid::Uuid; 6 7 use crate::DbError; 8 + use crate::scope::DbScope; 9 10 #[derive(Debug, Clone, Serialize, Deserialize)] 11 pub struct DelegationGrant { 12 pub id: Uuid, 13 pub delegated_did: Did, 14 pub controller_did: Did, 15 + pub granted_scopes: DbScope, 16 pub granted_at: DateTime<Utc>, 17 pub granted_by: Did, 18 pub revoked_at: Option<DateTime<Utc>>, ··· 23 pub struct DelegatedAccountInfo { 24 pub did: Did, 25 pub handle: Handle, 26 + pub granted_scopes: DbScope, 27 pub granted_at: DateTime<Utc>, 28 } 29 ··· 31 pub struct ControllerInfo { 32 pub did: Did, 33 pub handle: Handle, 34 + pub granted_scopes: DbScope, 35 pub granted_at: DateTime<Utc>, 36 pub is_active: bool, 37 } ··· 68 &self, 69 delegated_did: &Did, 70 controller_did: &Did, 71 + granted_scopes: &DbScope, 72 granted_by: &Did, 73 ) -> Result<Uuid, DbError>; 74 ··· 83 &self, 84 delegated_did: &Did, 85 controller_did: &Did, 86 + new_scopes: &DbScope, 87 ) -> Result<bool, DbError>; 88 89 async fn get_delegation(
+59 -7
crates/tranquil-db-traits/src/infra.rs
··· 5 use uuid::Uuid; 6 7 use crate::DbError; 8 9 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 10 pub enum InviteCodeSortOrder { ··· 13 Usage, 14 } 15 16 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 17 #[sqlx(type_name = "comms_channel", rename_all = "snake_case")] 18 pub enum CommsChannel { ··· 72 pub struct InviteCodeInfo { 73 pub code: String, 74 pub available_uses: i32, 75 - pub disabled: bool, 76 pub for_account: Option<Did>, 77 pub created_at: DateTime<Utc>, 78 pub created_by: Option<Did>, ··· 95 pub created_at: DateTime<Utc>, 96 } 97 98 #[derive(Debug, Clone)] 99 pub struct ReservedSigningKey { 100 pub id: Uuid, ··· 148 149 async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError>; 150 151 - async fn is_invite_code_valid(&self, code: &str) -> Result<bool, DbError>; 152 153 - async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError>; 154 155 - async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError>; 156 157 async fn get_invite_codes_for_account( 158 &self, ··· 317 #[derive(Debug, Clone)] 318 pub struct NotificationHistoryRow { 319 pub created_at: DateTime<Utc>, 320 - pub channel: String, 321 - pub comms_type: String, 322 - pub status: String, 323 pub subject: Option<String>, 324 pub body: String, 325 }
··· 5 use uuid::Uuid; 6 7 use crate::DbError; 8 + use crate::invite_code::{InviteCodeError, ValidatedInviteCode}; 9 10 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 11 pub enum InviteCodeSortOrder { ··· 14 Usage, 15 } 16 17 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] 18 + pub enum InviteCodeState { 19 + #[default] 20 + Active, 21 + Disabled, 22 + } 23 + 24 + impl InviteCodeState { 25 + pub fn is_active(self) -> bool { 26 + matches!(self, Self::Active) 27 + } 28 + 29 + pub fn is_disabled(self) -> bool { 30 + matches!(self, Self::Disabled) 31 + } 32 + } 33 + 34 + impl From<bool> for InviteCodeState { 35 + fn from(disabled: bool) -> Self { 36 + if disabled { Self::Disabled } else { Self::Active } 37 + } 38 + } 39 + 40 + impl From<Option<bool>> for InviteCodeState { 41 + fn from(disabled: Option<bool>) -> Self { 42 + Self::from(disabled.unwrap_or(false)) 43 + } 44 + } 45 + 46 + impl From<InviteCodeState> for bool { 47 + fn from(state: InviteCodeState) -> Self { 48 + matches!(state, InviteCodeState::Disabled) 49 + } 50 + } 51 + 52 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 53 #[sqlx(type_name = "comms_channel", rename_all = "snake_case")] 54 pub enum CommsChannel { ··· 108 pub struct InviteCodeInfo { 109 pub code: String, 110 pub available_uses: i32, 111 + pub state: InviteCodeState, 112 pub for_account: Option<Did>, 113 pub created_at: DateTime<Utc>, 114 pub created_by: Option<Did>, ··· 131 pub created_at: DateTime<Utc>, 132 } 133 134 + impl InviteCodeRow { 135 + pub fn state(&self) -> InviteCodeState { 136 + InviteCodeState::from(self.disabled) 137 + } 138 + } 139 + 140 #[derive(Debug, Clone)] 141 pub struct ReservedSigningKey { 142 pub id: Uuid, ··· 190 191 async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError>; 192 193 + async fn validate_invite_code<'a>( 194 + &self, 195 + code: &'a str, 196 + ) -> Result<ValidatedInviteCode<'a>, InviteCodeError>; 197 198 + async fn decrement_invite_code_uses( 199 + &self, 200 + code: &ValidatedInviteCode<'_>, 201 + ) -> Result<(), DbError>; 202 203 + async fn record_invite_code_use( 204 + &self, 205 + code: &ValidatedInviteCode<'_>, 206 + used_by_user: Uuid, 207 + ) -> Result<(), DbError>; 208 209 async fn get_invite_codes_for_account( 210 &self, ··· 369 #[derive(Debug, Clone)] 370 pub struct NotificationHistoryRow { 371 pub created_at: DateTime<Utc>, 372 + pub channel: CommsChannel, 373 + pub comms_type: CommsType, 374 + pub status: CommsStatus, 375 pub subject: Option<String>, 376 pub body: String, 377 }
+56
crates/tranquil-db-traits/src/invite_code.rs
···
··· 1 + use std::marker::PhantomData; 2 + 3 + use crate::DbError; 4 + 5 + #[derive(Debug)] 6 + pub struct ValidatedInviteCode<'a> { 7 + code: &'a str, 8 + _marker: PhantomData<&'a ()>, 9 + } 10 + 11 + impl<'a> ValidatedInviteCode<'a> { 12 + pub fn new_validated(code: &'a str) -> Self { 13 + Self { 14 + code, 15 + _marker: PhantomData, 16 + } 17 + } 18 + 19 + pub fn code(&self) -> &str { 20 + self.code 21 + } 22 + } 23 + 24 + #[derive(Debug)] 25 + pub enum InviteCodeError { 26 + NotFound, 27 + ExhaustedUses, 28 + Disabled, 29 + DatabaseError(DbError), 30 + } 31 + 32 + impl std::fmt::Display for InviteCodeError { 33 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 34 + match self { 35 + Self::NotFound => write!(f, "Invite code not found"), 36 + Self::ExhaustedUses => write!(f, "Invite code has no remaining uses"), 37 + Self::Disabled => write!(f, "Invite code is disabled"), 38 + Self::DatabaseError(e) => write!(f, "Database error: {}", e), 39 + } 40 + } 41 + } 42 + 43 + impl std::error::Error for InviteCodeError { 44 + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 45 + match self { 46 + Self::DatabaseError(e) => Some(e), 47 + _ => None, 48 + } 49 + } 50 + } 51 + 52 + impl From<DbError> for InviteCodeError { 53 + fn from(e: DbError) -> Self { 54 + Self::DatabaseError(e) 55 + } 56 + }
+30 -19
crates/tranquil-db-traits/src/lib.rs
··· 1 mod backlink; 2 mod backup; 3 mod blob; 4 mod delegation; 5 mod error; 6 mod infra; 7 mod oauth; 8 mod repo; 9 mod session; 10 mod sso; 11 mod user; ··· 16 OldBackupInfo, UserBackupInfo, 17 }; 18 pub use blob::{BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, MissingBlobInfo}; 19 pub use delegation::{ 20 AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant, 21 DelegationRepository, ··· 23 pub use error::DbError; 24 pub use infra::{ 25 AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, InfraRepository, 26 - InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, NotificationHistoryRow, 27 - QueuedComms, ReservedSigningKey, 28 }; 29 pub use oauth::{ 30 DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, RefreshTokenLookup, 31 - ScopePreference, TrustedDeviceRow, TwoFactorChallenge, 32 }; 33 pub use repo::{ 34 - ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, CommitEventData, 35 - EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, RecordDelete, 36 - RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier, 37 - RepoEventReceiver, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent, RepoWithoutRev, 38 - SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, 39 }; 40 pub use session::{ 41 - AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem, 42 - SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, 43 }; 44 pub use sso::{ 45 - ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, 46 }; 47 pub use user::{ 48 - AccountSearchResult, CompletePasskeySetupInput, CreateAccountError, 49 CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput, 50 CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides, 51 MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, 52 PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput, 53 RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, 54 - TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, 55 - UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, 56 - UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, 57 - UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, 58 - UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, 59 - UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, 60 - UserStatus, UserVerificationInfo, UserWithKey, 61 };
··· 1 mod backlink; 2 mod backup; 3 mod blob; 4 + mod channel_verification; 5 mod delegation; 6 mod error; 7 mod infra; 8 + mod invite_code; 9 mod oauth; 10 mod repo; 11 + mod scope; 12 + mod sequence; 13 mod session; 14 mod sso; 15 mod user; ··· 20 OldBackupInfo, UserBackupInfo, 21 }; 22 pub use blob::{BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, MissingBlobInfo}; 23 + pub use channel_verification::ChannelVerificationStatus; 24 pub use delegation::{ 25 AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant, 26 DelegationRepository, ··· 28 pub use error::DbError; 29 pub use infra::{ 30 AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, InfraRepository, 31 + InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, InviteCodeUse, 32 + NotificationHistoryRow, QueuedComms, ReservedSigningKey, 33 }; 34 + pub use invite_code::{InviteCodeError, ValidatedInviteCode}; 35 pub use oauth::{ 36 DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, RefreshTokenLookup, 37 + ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, 38 }; 39 pub use repo::{ 40 + AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, 41 + CommitEventData, EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, 42 + RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier, 43 + RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent, 44 + RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, 45 }; 46 + pub use scope::{DbScope, InvalidScopeError}; 47 + pub use sequence::{SequenceNumber, deserialize_optional_sequence}; 48 pub use session::{ 49 + AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, RefreshSessionResult, 50 + SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData, 51 + SessionRepository, SessionToken, SessionTokenCreate, 52 }; 53 pub use sso::{ 54 + ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction, SsoAuthState, 55 + SsoPendingRegistration, SsoProviderType, SsoRepository, 56 }; 57 pub use user::{ 58 + AccountSearchResult, AccountType, CompletePasskeySetupInput, CreateAccountError, 59 CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput, 60 CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides, 61 MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, 62 PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput, 63 RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, 64 + TotpRecord, TotpRecordState, UnverifiedTotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, 65 + UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, UserForDidDoc, 66 + UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery, 67 + UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, 68 + UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, 69 + UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification, 70 + UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey, 71 + VerifiedTotpRecord, 72 };
+43 -12
crates/tranquil-db-traits/src/oauth.rs
··· 10 11 use crate::DbError; 12 13 #[derive(Debug, Clone, Serialize, Deserialize)] 14 pub struct ScopePreference { 15 pub scope: String, ··· 53 54 #[derive(Debug, Clone)] 55 pub struct OAuthSessionListItem { 56 - pub id: i32, 57 pub token_id: TokenId, 58 pub created_at: DateTime<Utc>, 59 pub expires_at: DateTime<Utc>, ··· 62 63 pub enum RefreshTokenLookup { 64 Valid { 65 - db_id: i32, 66 token_data: TokenData, 67 }, 68 InGracePeriod { 69 - db_id: i32, 70 token_data: TokenData, 71 rotated_at: DateTime<Utc>, 72 }, 73 Used { 74 - original_token_id: i32, 75 }, 76 Expired { 77 - db_id: i32, 78 }, 79 NotFound, 80 } ··· 93 94 #[async_trait] 95 pub trait OAuthRepository: Send + Sync { 96 - async fn create_token(&self, data: &TokenData) -> Result<i32, DbError>; 97 async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError>; 98 async fn get_token_by_refresh_token( 99 &self, 100 refresh_token: &RefreshToken, 101 - ) -> Result<Option<(i32, TokenData)>, DbError>; 102 async fn get_token_by_previous_refresh_token( 103 &self, 104 refresh_token: &RefreshToken, 105 - ) -> Result<Option<(i32, TokenData)>, DbError>; 106 async fn rotate_token( 107 &self, 108 - old_db_id: i32, 109 new_refresh_token: &RefreshToken, 110 new_expires_at: DateTime<Utc>, 111 ) -> Result<(), DbError>; 112 async fn check_refresh_token_used( 113 &self, 114 refresh_token: &RefreshToken, 115 - ) -> Result<Option<i32>, DbError>; 116 async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError>; 117 - async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError>; 118 async fn list_tokens_for_user(&self, did: &Did) -> Result<Vec<TokenData>, DbError>; 119 async fn count_tokens_for_user(&self, did: &Did) -> Result<i64, DbError>; 120 async fn delete_oldest_tokens_for_user( ··· 274 ) -> Result<(), DbError>; 275 276 async fn list_sessions_by_did(&self, did: &Did) -> Result<Vec<OAuthSessionListItem>, DbError>; 277 - async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result<u64, DbError>; 278 async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>; 279 async fn delete_sessions_by_did_except( 280 &self,
··· 10 11 use crate::DbError; 12 13 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 14 + pub struct TokenFamilyId(i32); 15 + 16 + impl TokenFamilyId { 17 + pub fn new(id: i32) -> Self { 18 + Self(id) 19 + } 20 + 21 + pub fn as_i32(self) -> i32 { 22 + self.0 23 + } 24 + } 25 + 26 + impl From<i32> for TokenFamilyId { 27 + fn from(id: i32) -> Self { 28 + Self(id) 29 + } 30 + } 31 + 32 + impl From<TokenFamilyId> for i32 { 33 + fn from(id: TokenFamilyId) -> Self { 34 + id.0 35 + } 36 + } 37 + 38 + impl std::fmt::Display for TokenFamilyId { 39 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 + write!(f, "{}", self.0) 41 + } 42 + } 43 + 44 #[derive(Debug, Clone, Serialize, Deserialize)] 45 pub struct ScopePreference { 46 pub scope: String, ··· 84 85 #[derive(Debug, Clone)] 86 pub struct OAuthSessionListItem { 87 + pub id: TokenFamilyId, 88 pub token_id: TokenId, 89 pub created_at: DateTime<Utc>, 90 pub expires_at: DateTime<Utc>, ··· 93 94 pub enum RefreshTokenLookup { 95 Valid { 96 + db_id: TokenFamilyId, 97 token_data: TokenData, 98 }, 99 InGracePeriod { 100 + db_id: TokenFamilyId, 101 token_data: TokenData, 102 rotated_at: DateTime<Utc>, 103 }, 104 Used { 105 + original_token_id: TokenFamilyId, 106 }, 107 Expired { 108 + db_id: TokenFamilyId, 109 }, 110 NotFound, 111 } ··· 124 125 #[async_trait] 126 pub trait OAuthRepository: Send + Sync { 127 + async fn create_token(&self, data: &TokenData) -> Result<TokenFamilyId, DbError>; 128 async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError>; 129 async fn get_token_by_refresh_token( 130 &self, 131 refresh_token: &RefreshToken, 132 + ) -> Result<Option<(TokenFamilyId, TokenData)>, DbError>; 133 async fn get_token_by_previous_refresh_token( 134 &self, 135 refresh_token: &RefreshToken, 136 + ) -> Result<Option<(TokenFamilyId, TokenData)>, DbError>; 137 async fn rotate_token( 138 &self, 139 + old_db_id: TokenFamilyId, 140 new_refresh_token: &RefreshToken, 141 new_expires_at: DateTime<Utc>, 142 ) -> Result<(), DbError>; 143 async fn check_refresh_token_used( 144 &self, 145 refresh_token: &RefreshToken, 146 + ) -> Result<Option<TokenFamilyId>, DbError>; 147 async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError>; 148 + async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError>; 149 async fn list_tokens_for_user(&self, did: &Did) -> Result<Vec<TokenData>, DbError>; 150 async fn count_tokens_for_user(&self, did: &Did) -> Result<i64, DbError>; 151 async fn delete_oldest_tokens_for_user( ··· 305 ) -> Result<(), DbError>; 306 307 async fn list_sessions_by_did(&self, did: &Did) -> Result<Vec<OAuthSessionListItem>, DbError>; 308 + async fn delete_session_by_id(&self, session_id: TokenFamilyId, did: &Did) -> Result<u64, DbError>; 309 async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>; 310 async fn delete_sessions_by_did_except( 311 &self,
+146 -24
crates/tranquil-db-traits/src/repo.rs
··· 5 use uuid::Uuid; 6 7 use crate::DbError; 8 9 #[derive(Debug, Clone, Serialize, Deserialize)] 10 pub struct RepoAccountInfo { ··· 49 50 #[derive(Debug, Clone)] 51 pub struct BrokenGenesisCommit { 52 - pub seq: i64, 53 pub did: Did, 54 pub commit_cid: Option<CidLink>, 55 } ··· 69 70 #[derive(Debug, Clone, Serialize, Deserialize)] 71 pub struct RepoSeqEvent { 72 - pub seq: i64, 73 } 74 75 #[derive(Debug, Clone, Serialize, Deserialize)] 76 pub struct SequencedEvent { 77 - pub seq: i64, 78 pub did: Did, 79 pub created_at: DateTime<Utc>, 80 - pub event_type: String, 81 pub commit_cid: Option<CidLink>, 82 pub prev_cid: Option<CidLink>, 83 pub prev_data_cid: Option<CidLink>, ··· 86 pub blocks_cids: Option<Vec<String>>, 87 pub handle: Option<Handle>, 88 pub active: Option<bool>, 89 - pub status: Option<String>, 90 pub rev: Option<String>, 91 } 92 93 #[derive(Debug, Clone)] 94 pub struct CommitEventData { 95 pub did: Did, 96 - pub event_type: String, 97 pub commit_cid: Option<CidLink>, 98 pub prev_cid: Option<CidLink>, 99 pub ops: Option<serde_json::Value>, ··· 283 284 async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>; 285 286 - async fn insert_commit_event(&self, data: &CommitEventData) -> Result<i64, DbError>; 287 288 async fn insert_identity_event( 289 &self, 290 did: &Did, 291 handle: Option<&Handle>, 292 - ) -> Result<i64, DbError>; 293 294 async fn insert_account_event( 295 &self, 296 did: &Did, 297 - active: bool, 298 - status: Option<&str>, 299 - ) -> Result<i64, DbError>; 300 301 async fn insert_sync_event( 302 &self, 303 did: &Did, 304 commit_cid: &CidLink, 305 rev: Option<&str>, 306 - ) -> Result<i64, DbError>; 307 308 async fn insert_genesis_commit_event( 309 &self, ··· 311 commit_cid: &CidLink, 312 mst_root_cid: &CidLink, 313 rev: &str, 314 - ) -> Result<i64, DbError>; 315 316 - async fn update_seq_blocks_cids(&self, seq: i64, blocks_cids: &[String]) 317 - -> Result<(), DbError>; 318 319 - async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError>; 320 321 - async fn get_max_seq(&self) -> Result<i64, DbError>; 322 323 - async fn get_min_seq_since(&self, since: DateTime<Utc>) -> Result<Option<i64>, DbError>; 324 325 async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError>; 326 327 async fn get_events_since_seq( 328 &self, 329 - since_seq: i64, 330 limit: Option<i64>, 331 ) -> Result<Vec<SequencedEvent>, DbError>; 332 333 async fn get_events_in_seq_range( 334 &self, 335 - start_seq: i64, 336 - end_seq: i64, 337 ) -> Result<Vec<SequencedEvent>, DbError>; 338 339 - async fn get_event_by_seq(&self, seq: i64) -> Result<Option<SequencedEvent>, DbError>; 340 341 async fn get_events_since_cursor( 342 &self, 343 - cursor: i64, 344 limit: i64, 345 ) -> Result<Vec<SequencedEvent>, DbError>; 346 ··· 359 async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid) 360 -> Result<Option<CidLink>, DbError>; 361 362 - async fn notify_update(&self, seq: i64) -> Result<(), DbError>; 363 364 async fn import_repo_data( 365 &self,
··· 5 use uuid::Uuid; 6 7 use crate::DbError; 8 + use crate::sequence::SequenceNumber; 9 + 10 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 11 + #[sqlx(type_name = "text", rename_all = "snake_case")] 12 + #[serde(rename_all = "snake_case")] 13 + pub enum RepoEventType { 14 + Commit, 15 + Identity, 16 + Account, 17 + Sync, 18 + } 19 + 20 + impl RepoEventType { 21 + pub fn as_str(&self) -> &'static str { 22 + match self { 23 + Self::Commit => "commit", 24 + Self::Identity => "identity", 25 + Self::Account => "account", 26 + Self::Sync => "sync", 27 + } 28 + } 29 + } 30 + 31 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 32 + #[sqlx(type_name = "text", rename_all = "lowercase")] 33 + #[serde(rename_all = "lowercase")] 34 + pub enum AccountStatus { 35 + Active, 36 + Takendown, 37 + Suspended, 38 + Deactivated, 39 + Deleted, 40 + } 41 + 42 + impl AccountStatus { 43 + pub fn as_str(&self) -> &'static str { 44 + match self { 45 + Self::Active => "active", 46 + Self::Takendown => "takendown", 47 + Self::Suspended => "suspended", 48 + Self::Deactivated => "deactivated", 49 + Self::Deleted => "deleted", 50 + } 51 + } 52 + 53 + pub fn for_firehose(&self) -> Option<&'static str> { 54 + match self { 55 + Self::Active => None, 56 + other => Some(other.as_str()), 57 + } 58 + } 59 + 60 + pub fn parse(s: &str) -> Option<Self> { 61 + match s.to_lowercase().as_str() { 62 + "active" => Some(Self::Active), 63 + "takendown" => Some(Self::Takendown), 64 + "suspended" => Some(Self::Suspended), 65 + "deactivated" => Some(Self::Deactivated), 66 + "deleted" => Some(Self::Deleted), 67 + _ => None, 68 + } 69 + } 70 + 71 + pub fn is_active(&self) -> bool { 72 + matches!(self, Self::Active) 73 + } 74 + 75 + pub fn is_takendown(&self) -> bool { 76 + matches!(self, Self::Takendown) 77 + } 78 + 79 + pub fn is_deactivated(&self) -> bool { 80 + matches!(self, Self::Deactivated) 81 + } 82 + 83 + pub fn is_suspended(&self) -> bool { 84 + matches!(self, Self::Suspended) 85 + } 86 + 87 + pub fn is_deleted(&self) -> bool { 88 + matches!(self, Self::Deleted) 89 + } 90 + 91 + pub fn allows_read(&self) -> bool { 92 + matches!(self, Self::Active | Self::Deactivated) 93 + } 94 + 95 + pub fn allows_write(&self) -> bool { 96 + matches!(self, Self::Active) 97 + } 98 + 99 + pub fn from_db_fields( 100 + takedown_ref: Option<&str>, 101 + deactivated_at: Option<DateTime<Utc>>, 102 + ) -> Self { 103 + if takedown_ref.is_some() { 104 + Self::Takendown 105 + } else if deactivated_at.is_some() { 106 + Self::Deactivated 107 + } else { 108 + Self::Active 109 + } 110 + } 111 + } 112 + 113 + impl std::fmt::Display for AccountStatus { 114 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 115 + f.write_str(self.as_str()) 116 + } 117 + } 118 119 #[derive(Debug, Clone, Serialize, Deserialize)] 120 pub struct RepoAccountInfo { ··· 159 160 #[derive(Debug, Clone)] 161 pub struct BrokenGenesisCommit { 162 + pub seq: SequenceNumber, 163 pub did: Did, 164 pub commit_cid: Option<CidLink>, 165 } ··· 179 180 #[derive(Debug, Clone, Serialize, Deserialize)] 181 pub struct RepoSeqEvent { 182 + pub seq: SequenceNumber, 183 } 184 185 #[derive(Debug, Clone, Serialize, Deserialize)] 186 pub struct SequencedEvent { 187 + pub seq: SequenceNumber, 188 pub did: Did, 189 pub created_at: DateTime<Utc>, 190 + pub event_type: RepoEventType, 191 pub commit_cid: Option<CidLink>, 192 pub prev_cid: Option<CidLink>, 193 pub prev_data_cid: Option<CidLink>, ··· 196 pub blocks_cids: Option<Vec<String>>, 197 pub handle: Option<Handle>, 198 pub active: Option<bool>, 199 + pub status: Option<AccountStatus>, 200 pub rev: Option<String>, 201 } 202 203 #[derive(Debug, Clone)] 204 pub struct CommitEventData { 205 pub did: Did, 206 + pub event_type: RepoEventType, 207 pub commit_cid: Option<CidLink>, 208 pub prev_cid: Option<CidLink>, 209 pub ops: Option<serde_json::Value>, ··· 393 394 async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>; 395 396 + async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>; 397 398 async fn insert_identity_event( 399 &self, 400 did: &Did, 401 handle: Option<&Handle>, 402 + ) -> Result<SequenceNumber, DbError>; 403 404 async fn insert_account_event( 405 &self, 406 did: &Did, 407 + status: AccountStatus, 408 + ) -> Result<SequenceNumber, DbError>; 409 410 async fn insert_sync_event( 411 &self, 412 did: &Did, 413 commit_cid: &CidLink, 414 rev: Option<&str>, 415 + ) -> Result<SequenceNumber, DbError>; 416 417 async fn insert_genesis_commit_event( 418 &self, ··· 420 commit_cid: &CidLink, 421 mst_root_cid: &CidLink, 422 rev: &str, 423 + ) -> Result<SequenceNumber, DbError>; 424 425 + async fn update_seq_blocks_cids( 426 + &self, 427 + seq: SequenceNumber, 428 + blocks_cids: &[String], 429 + ) -> Result<(), DbError>; 430 431 + async fn delete_sequences_except( 432 + &self, 433 + did: &Did, 434 + keep_seq: SequenceNumber, 435 + ) -> Result<(), DbError>; 436 437 + async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>; 438 439 + async fn get_min_seq_since( 440 + &self, 441 + since: DateTime<Utc>, 442 + ) -> Result<Option<SequenceNumber>, DbError>; 443 444 async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError>; 445 446 async fn get_events_since_seq( 447 &self, 448 + since_seq: SequenceNumber, 449 limit: Option<i64>, 450 ) -> Result<Vec<SequencedEvent>, DbError>; 451 452 async fn get_events_in_seq_range( 453 &self, 454 + start_seq: SequenceNumber, 455 + end_seq: SequenceNumber, 456 ) -> Result<Vec<SequencedEvent>, DbError>; 457 458 + async fn get_event_by_seq( 459 + &self, 460 + seq: SequenceNumber, 461 + ) -> Result<Option<SequencedEvent>, DbError>; 462 463 async fn get_events_since_cursor( 464 &self, 465 + cursor: SequenceNumber, 466 limit: i64, 467 ) -> Result<Vec<SequencedEvent>, DbError>; 468 ··· 481 async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid) 482 -> Result<Option<CidLink>, DbError>; 483 484 + async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError>; 485 486 async fn import_repo_data( 487 &self,
+165
crates/tranquil-db-traits/src/scope.rs
···
··· 1 + use serde::{Deserialize, Deserializer, Serialize}; 2 + use std::fmt; 3 + 4 + #[derive(Debug, Clone, PartialEq, Eq)] 5 + pub struct DbScope(String); 6 + 7 + impl DbScope { 8 + pub fn new(scope: impl Into<String>) -> Result<Self, InvalidScopeError> { 9 + let scope = scope.into(); 10 + validate_scope_string(&scope)?; 11 + Ok(Self(scope)) 12 + } 13 + 14 + pub fn empty() -> Self { 15 + Self(String::new()) 16 + } 17 + 18 + pub fn from_db_unchecked(scope: String) -> Self { 19 + Self(scope) 20 + } 21 + 22 + pub fn as_str(&self) -> &str { 23 + &self.0 24 + } 25 + 26 + pub fn into_string(self) -> String { 27 + self.0 28 + } 29 + 30 + pub fn is_empty(&self) -> bool { 31 + self.0.is_empty() 32 + } 33 + } 34 + 35 + impl Default for DbScope { 36 + fn default() -> Self { 37 + Self::empty() 38 + } 39 + } 40 + 41 + impl fmt::Display for DbScope { 42 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 43 + write!(f, "{}", self.0) 44 + } 45 + } 46 + 47 + impl AsRef<str> for DbScope { 48 + fn as_ref(&self) -> &str { 49 + &self.0 50 + } 51 + } 52 + 53 + impl Serialize for DbScope { 54 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 55 + where 56 + S: serde::Serializer, 57 + { 58 + self.0.serialize(serializer) 59 + } 60 + } 61 + 62 + impl<'de> Deserialize<'de> for DbScope { 63 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 64 + where 65 + D: Deserializer<'de>, 66 + { 67 + let s = String::deserialize(deserializer)?; 68 + Self::new(s).map_err(serde::de::Error::custom) 69 + } 70 + } 71 + 72 + #[derive(Debug, Clone)] 73 + pub struct InvalidScopeError { 74 + message: String, 75 + } 76 + 77 + impl InvalidScopeError { 78 + pub fn new(message: impl Into<String>) -> Self { 79 + Self { 80 + message: message.into(), 81 + } 82 + } 83 + 84 + pub fn message(&self) -> &str { 85 + &self.message 86 + } 87 + } 88 + 89 + impl fmt::Display for InvalidScopeError { 90 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 91 + write!(f, "{}", self.message) 92 + } 93 + } 94 + 95 + impl std::error::Error for InvalidScopeError {} 96 + 97 + fn validate_scope_string(scopes: &str) -> Result<(), InvalidScopeError> { 98 + if scopes.is_empty() { 99 + return Ok(()); 100 + } 101 + 102 + scopes.split_whitespace().try_for_each(|scope| { 103 + let base = scope.split_once('?').map_or(scope, |(b, _)| b); 104 + if is_valid_scope_prefix(base) { 105 + Ok(()) 106 + } else { 107 + Err(InvalidScopeError::new(format!("Invalid scope: {}", scope))) 108 + } 109 + }) 110 + } 111 + 112 + fn is_valid_scope_prefix(base: &str) -> bool { 113 + const VALID_PREFIXES: [&str; 8] = [ 114 + "atproto", 115 + "repo:", 116 + "blob:", 117 + "rpc:", 118 + "account:", 119 + "identity:", 120 + "transition:", 121 + "include:", 122 + ]; 123 + 124 + VALID_PREFIXES 125 + .iter() 126 + .any(|prefix| base == prefix.trim_end_matches(':') || base.starts_with(prefix)) 127 + } 128 + 129 + #[cfg(test)] 130 + mod tests { 131 + use super::*; 132 + 133 + #[test] 134 + fn test_valid_scopes() { 135 + assert!(DbScope::new("atproto").is_ok()); 136 + assert!(DbScope::new("repo:*").is_ok()); 137 + assert!(DbScope::new("blob:*/*").is_ok()); 138 + assert!(DbScope::new("repo:* blob:*/*").is_ok()); 139 + assert!(DbScope::new("").is_ok()); 140 + assert!(DbScope::new("account:email?action=read").is_ok()); 141 + assert!(DbScope::new("identity:handle").is_ok()); 142 + assert!(DbScope::new("transition:generic").is_ok()); 143 + assert!(DbScope::new("include:app.bsky.authFullApp").is_ok()); 144 + } 145 + 146 + #[test] 147 + fn test_invalid_scopes() { 148 + assert!(DbScope::new("invalid:scope").is_err()); 149 + assert!(DbScope::new("garbage").is_err()); 150 + assert!(DbScope::new("repo:* invalid:scope").is_err()); 151 + } 152 + 153 + #[test] 154 + fn test_empty_scope() { 155 + let scope = DbScope::empty(); 156 + assert!(scope.is_empty()); 157 + assert_eq!(scope.as_str(), ""); 158 + } 159 + 160 + #[test] 161 + fn test_display() { 162 + let scope = DbScope::new("repo:*").unwrap(); 163 + assert_eq!(format!("{}", scope), "repo:*"); 164 + } 165 + }
+73
crates/tranquil-db-traits/src/sequence.rs
···
··· 1 + use serde::{Deserialize, Deserializer, Serialize, Serializer}; 2 + use std::fmt; 3 + 4 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 5 + pub struct SequenceNumber(i64); 6 + 7 + impl SequenceNumber { 8 + pub const ZERO: Self = Self(0); 9 + pub const UNSET: Self = Self(-1); 10 + 11 + pub fn new(n: i64) -> Option<Self> { 12 + if n >= 0 { Some(Self(n)) } else { None } 13 + } 14 + 15 + pub fn from_raw(n: i64) -> Self { 16 + Self(n) 17 + } 18 + 19 + pub fn as_i64(&self) -> i64 { 20 + self.0 21 + } 22 + 23 + pub fn is_valid(&self) -> bool { 24 + self.0 >= 0 25 + } 26 + } 27 + 28 + impl fmt::Display for SequenceNumber { 29 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 30 + write!(f, "{}", self.0) 31 + } 32 + } 33 + 34 + impl From<i64> for SequenceNumber { 35 + fn from(n: i64) -> Self { 36 + Self(n) 37 + } 38 + } 39 + 40 + impl From<SequenceNumber> for i64 { 41 + fn from(seq: SequenceNumber) -> Self { 42 + seq.0 43 + } 44 + } 45 + 46 + impl Serialize for SequenceNumber { 47 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 48 + where 49 + S: Serializer, 50 + { 51 + self.0.serialize(serializer) 52 + } 53 + } 54 + 55 + impl<'de> Deserialize<'de> for SequenceNumber { 56 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 57 + where 58 + D: Deserializer<'de>, 59 + { 60 + let n = i64::deserialize(deserializer)?; 61 + Ok(Self(n)) 62 + } 63 + } 64 + 65 + pub fn deserialize_optional_sequence<'de, D>( 66 + deserializer: D, 67 + ) -> Result<Option<SequenceNumber>, D::Error> 68 + where 69 + D: Deserializer<'de>, 70 + { 71 + let opt: Option<i64> = Option::deserialize(deserializer)?; 72 + Ok(opt.map(SequenceNumber::from_raw)) 73 + }
+100 -15
crates/tranquil-db-traits/src/session.rs
··· 5 6 use crate::DbError; 7 8 #[derive(Debug, Clone)] 9 pub struct SessionToken { 10 - pub id: i32, 11 pub did: Did, 12 pub access_jti: String, 13 pub refresh_jti: String, 14 pub access_expires_at: DateTime<Utc>, 15 pub refresh_expires_at: DateTime<Utc>, 16 - pub legacy_login: bool, 17 pub mfa_verified: bool, 18 pub scope: Option<String>, 19 pub controller_did: Option<Did>, ··· 29 pub refresh_jti: String, 30 pub access_expires_at: DateTime<Utc>, 31 pub refresh_expires_at: DateTime<Utc>, 32 - pub legacy_login: bool, 33 pub mfa_verified: bool, 34 pub scope: Option<String>, 35 pub controller_did: Option<Did>, ··· 38 39 #[derive(Debug, Clone)] 40 pub struct SessionForRefresh { 41 - pub id: i32, 42 pub did: Did, 43 pub scope: Option<String>, 44 pub controller_did: Option<Did>, ··· 48 49 #[derive(Debug, Clone)] 50 pub struct SessionListItem { 51 - pub id: i32, 52 pub access_jti: String, 53 pub created_at: DateTime<Utc>, 54 pub refresh_expires_at: DateTime<Utc>, ··· 61 pub name: String, 62 pub password_hash: String, 63 pub created_at: DateTime<Utc>, 64 - pub privileged: bool, 65 pub scopes: Option<String>, 66 pub created_by_controller_did: Option<Did>, 67 } ··· 71 pub user_id: Uuid, 72 pub name: String, 73 pub password_hash: String, 74 - pub privileged: bool, 75 pub scopes: Option<String>, 76 pub created_by_controller_did: Option<Did>, 77 } 78 79 #[derive(Debug, Clone)] 80 pub struct SessionMfaStatus { 81 - pub legacy_login: bool, 82 pub mfa_verified: bool, 83 pub last_reauth_at: Option<DateTime<Utc>>, 84 } ··· 93 #[derive(Debug, Clone)] 94 pub struct SessionRefreshData { 95 pub old_refresh_jti: String, 96 - pub session_id: i32, 97 pub new_access_jti: String, 98 pub new_refresh_jti: String, 99 pub new_access_expires_at: DateTime<Utc>, ··· 102 103 #[async_trait] 104 pub trait SessionRepository: Send + Sync { 105 - async fn create_session(&self, data: &SessionTokenCreate) -> Result<i32, DbError>; 106 107 async fn get_session_by_access_jti( 108 &self, ··· 116 117 async fn update_session_tokens( 118 &self, 119 - session_id: i32, 120 new_access_jti: &str, 121 new_refresh_jti: &str, 122 new_access_expires_at: DateTime<Utc>, ··· 125 126 async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError>; 127 128 - async fn delete_session_by_id(&self, session_id: i32) -> Result<u64, DbError>; 129 130 async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>; 131 ··· 139 140 async fn get_session_access_jti_by_id( 141 &self, 142 - session_id: i32, 143 did: &Did, 144 ) -> Result<Option<String>, DbError>; 145 ··· 155 app_password_name: &str, 156 ) -> Result<Vec<String>, DbError>; 157 158 - async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<i32>, DbError>; 159 160 async fn mark_refresh_token_used( 161 &self, 162 refresh_jti: &str, 163 - session_id: i32, 164 ) -> Result<bool, DbError>; 165 166 async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
··· 5 6 use crate::DbError; 7 8 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] 9 + pub enum LoginType { 10 + #[default] 11 + Modern, 12 + Legacy, 13 + } 14 + 15 + impl LoginType { 16 + pub fn is_legacy(self) -> bool { 17 + matches!(self, Self::Legacy) 18 + } 19 + 20 + pub fn is_modern(self) -> bool { 21 + matches!(self, Self::Modern) 22 + } 23 + } 24 + 25 + impl From<bool> for LoginType { 26 + fn from(legacy: bool) -> Self { 27 + if legacy { Self::Legacy } else { Self::Modern } 28 + } 29 + } 30 + 31 + impl From<LoginType> for bool { 32 + fn from(lt: LoginType) -> Self { 33 + matches!(lt, LoginType::Legacy) 34 + } 35 + } 36 + 37 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] 38 + pub enum AppPasswordPrivilege { 39 + #[default] 40 + Standard, 41 + Privileged, 42 + } 43 + 44 + impl AppPasswordPrivilege { 45 + pub fn is_privileged(self) -> bool { 46 + matches!(self, Self::Privileged) 47 + } 48 + } 49 + 50 + impl From<bool> for AppPasswordPrivilege { 51 + fn from(privileged: bool) -> Self { 52 + if privileged { Self::Privileged } else { Self::Standard } 53 + } 54 + } 55 + 56 + impl From<AppPasswordPrivilege> for bool { 57 + fn from(p: AppPasswordPrivilege) -> Self { 58 + matches!(p, AppPasswordPrivilege::Privileged) 59 + } 60 + } 61 + 62 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 63 + pub struct SessionId(i32); 64 + 65 + impl SessionId { 66 + pub fn new(id: i32) -> Self { 67 + Self(id) 68 + } 69 + 70 + pub fn as_i32(self) -> i32 { 71 + self.0 72 + } 73 + } 74 + 75 + impl From<i32> for SessionId { 76 + fn from(id: i32) -> Self { 77 + Self(id) 78 + } 79 + } 80 + 81 + impl From<SessionId> for i32 { 82 + fn from(id: SessionId) -> Self { 83 + id.0 84 + } 85 + } 86 + 87 + impl std::fmt::Display for SessionId { 88 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 89 + write!(f, "{}", self.0) 90 + } 91 + } 92 + 93 #[derive(Debug, Clone)] 94 pub struct SessionToken { 95 + pub id: SessionId, 96 pub did: Did, 97 pub access_jti: String, 98 pub refresh_jti: String, 99 pub access_expires_at: DateTime<Utc>, 100 pub refresh_expires_at: DateTime<Utc>, 101 + pub login_type: LoginType, 102 pub mfa_verified: bool, 103 pub scope: Option<String>, 104 pub controller_did: Option<Did>, ··· 114 pub refresh_jti: String, 115 pub access_expires_at: DateTime<Utc>, 116 pub refresh_expires_at: DateTime<Utc>, 117 + pub login_type: LoginType, 118 pub mfa_verified: bool, 119 pub scope: Option<String>, 120 pub controller_did: Option<Did>, ··· 123 124 #[derive(Debug, Clone)] 125 pub struct SessionForRefresh { 126 + pub id: SessionId, 127 pub did: Did, 128 pub scope: Option<String>, 129 pub controller_did: Option<Did>, ··· 133 134 #[derive(Debug, Clone)] 135 pub struct SessionListItem { 136 + pub id: SessionId, 137 pub access_jti: String, 138 pub created_at: DateTime<Utc>, 139 pub refresh_expires_at: DateTime<Utc>, ··· 146 pub name: String, 147 pub password_hash: String, 148 pub created_at: DateTime<Utc>, 149 + pub privilege: AppPasswordPrivilege, 150 pub scopes: Option<String>, 151 pub created_by_controller_did: Option<Did>, 152 } ··· 156 pub user_id: Uuid, 157 pub name: String, 158 pub password_hash: String, 159 + pub privilege: AppPasswordPrivilege, 160 pub scopes: Option<String>, 161 pub created_by_controller_did: Option<Did>, 162 } 163 164 #[derive(Debug, Clone)] 165 pub struct SessionMfaStatus { 166 + pub login_type: LoginType, 167 pub mfa_verified: bool, 168 pub last_reauth_at: Option<DateTime<Utc>>, 169 } ··· 178 #[derive(Debug, Clone)] 179 pub struct SessionRefreshData { 180 pub old_refresh_jti: String, 181 + pub session_id: SessionId, 182 pub new_access_jti: String, 183 pub new_refresh_jti: String, 184 pub new_access_expires_at: DateTime<Utc>, ··· 187 188 #[async_trait] 189 pub trait SessionRepository: Send + Sync { 190 + async fn create_session(&self, data: &SessionTokenCreate) -> Result<SessionId, DbError>; 191 192 async fn get_session_by_access_jti( 193 &self, ··· 201 202 async fn update_session_tokens( 203 &self, 204 + session_id: SessionId, 205 new_access_jti: &str, 206 new_refresh_jti: &str, 207 new_access_expires_at: DateTime<Utc>, ··· 210 211 async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError>; 212 213 + async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError>; 214 215 async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>; 216 ··· 224 225 async fn get_session_access_jti_by_id( 226 &self, 227 + session_id: SessionId, 228 did: &Did, 229 ) -> Result<Option<String>, DbError>; 230 ··· 240 app_password_name: &str, 241 ) -> Result<Vec<String>, DbError>; 242 243 + async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<SessionId>, DbError>; 244 245 async fn mark_refresh_token_used( 246 &self, 247 refresh_jti: &str, 248 + session_id: SessionId, 249 ) -> Result<bool, DbError>; 250 251 async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
+147 -8
crates/tranquil-db-traits/src/sso.rs
··· 6 7 use crate::DbError; 8 9 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 10 #[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")] 11 pub enum SsoProviderType { ··· 17 Apple, 18 } 19 20 impl SsoProviderType { 21 pub fn as_str(&self) -> &'static str { 22 match self { ··· 69 pub id: Uuid, 70 pub did: Did, 71 pub provider: SsoProviderType, 72 - pub provider_user_id: String, 73 - pub provider_username: Option<String>, 74 - pub provider_email: Option<String>, 75 pub created_at: DateTime<Utc>, 76 pub updated_at: DateTime<Utc>, 77 pub last_login_at: Option<DateTime<Utc>>, ··· 82 pub state: String, 83 pub request_uri: String, 84 pub provider: SsoProviderType, 85 - pub action: String, 86 pub nonce: Option<String>, 87 pub code_verifier: Option<String>, 88 pub did: Option<Did>, ··· 95 pub token: String, 96 pub request_uri: String, 97 pub provider: SsoProviderType, 98 - pub provider_user_id: String, 99 - pub provider_username: Option<String>, 100 - pub provider_email: Option<String>, 101 pub provider_email_verified: bool, 102 pub created_at: DateTime<Utc>, 103 pub expires_at: DateTime<Utc>, ··· 140 state: &str, 141 request_uri: &str, 142 provider: SsoProviderType, 143 - action: &str, 144 nonce: Option<&str>, 145 code_verifier: Option<&str>, 146 did: Option<&Did>,
··· 6 7 use crate::DbError; 8 9 + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 10 + pub struct ExternalUserId(String); 11 + 12 + impl ExternalUserId { 13 + pub fn new(id: impl Into<String>) -> Self { 14 + Self(id.into()) 15 + } 16 + 17 + pub fn as_str(&self) -> &str { 18 + &self.0 19 + } 20 + 21 + pub fn into_inner(self) -> String { 22 + self.0 23 + } 24 + } 25 + 26 + impl std::fmt::Display for ExternalUserId { 27 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 28 + write!(f, "{}", self.0) 29 + } 30 + } 31 + 32 + impl From<String> for ExternalUserId { 33 + fn from(s: String) -> Self { 34 + Self(s) 35 + } 36 + } 37 + 38 + impl From<ExternalUserId> for String { 39 + fn from(id: ExternalUserId) -> Self { 40 + id.0 41 + } 42 + } 43 + 44 + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 45 + pub struct ExternalUsername(String); 46 + 47 + impl ExternalUsername { 48 + pub fn new(username: impl Into<String>) -> Self { 49 + Self(username.into()) 50 + } 51 + 52 + pub fn as_str(&self) -> &str { 53 + &self.0 54 + } 55 + 56 + pub fn into_inner(self) -> String { 57 + self.0 58 + } 59 + } 60 + 61 + impl std::fmt::Display for ExternalUsername { 62 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 63 + write!(f, "{}", self.0) 64 + } 65 + } 66 + 67 + impl From<String> for ExternalUsername { 68 + fn from(s: String) -> Self { 69 + Self(s) 70 + } 71 + } 72 + 73 + impl From<ExternalUsername> for String { 74 + fn from(username: ExternalUsername) -> Self { 75 + username.0 76 + } 77 + } 78 + 79 + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 80 + pub struct ExternalEmail(String); 81 + 82 + impl ExternalEmail { 83 + pub fn new(email: impl Into<String>) -> Self { 84 + Self(email.into()) 85 + } 86 + 87 + pub fn as_str(&self) -> &str { 88 + &self.0 89 + } 90 + 91 + pub fn into_inner(self) -> String { 92 + self.0 93 + } 94 + } 95 + 96 + impl std::fmt::Display for ExternalEmail { 97 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 98 + write!(f, "{}", self.0) 99 + } 100 + } 101 + 102 + impl From<String> for ExternalEmail { 103 + fn from(s: String) -> Self { 104 + Self(s) 105 + } 106 + } 107 + 108 + impl From<ExternalEmail> for String { 109 + fn from(email: ExternalEmail) -> Self { 110 + email.0 111 + } 112 + } 113 + 114 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 115 #[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")] 116 pub enum SsoProviderType { ··· 122 Apple, 123 } 124 125 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 126 + #[sqlx(type_name = "text", rename_all = "lowercase")] 127 + #[serde(rename_all = "lowercase")] 128 + pub enum SsoAction { 129 + Login, 130 + Link, 131 + Register, 132 + } 133 + 134 + impl SsoAction { 135 + pub fn as_str(&self) -> &'static str { 136 + match self { 137 + Self::Login => "login", 138 + Self::Link => "link", 139 + Self::Register => "register", 140 + } 141 + } 142 + 143 + pub fn parse(s: &str) -> Option<Self> { 144 + match s.to_lowercase().as_str() { 145 + "login" => Some(Self::Login), 146 + "link" => Some(Self::Link), 147 + "register" => Some(Self::Register), 148 + _ => None, 149 + } 150 + } 151 + } 152 + 153 + impl std::fmt::Display for SsoAction { 154 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 155 + f.write_str(self.as_str()) 156 + } 157 + } 158 + 159 impl SsoProviderType { 160 pub fn as_str(&self) -> &'static str { 161 match self { ··· 208 pub id: Uuid, 209 pub did: Did, 210 pub provider: SsoProviderType, 211 + pub provider_user_id: ExternalUserId, 212 + pub provider_username: Option<ExternalUsername>, 213 + pub provider_email: Option<ExternalEmail>, 214 pub created_at: DateTime<Utc>, 215 pub updated_at: DateTime<Utc>, 216 pub last_login_at: Option<DateTime<Utc>>, ··· 221 pub state: String, 222 pub request_uri: String, 223 pub provider: SsoProviderType, 224 + pub action: SsoAction, 225 pub nonce: Option<String>, 226 pub code_verifier: Option<String>, 227 pub did: Option<Did>, ··· 234 pub token: String, 235 pub request_uri: String, 236 pub provider: SsoProviderType, 237 + pub provider_user_id: ExternalUserId, 238 + pub provider_username: Option<ExternalUsername>, 239 + pub provider_email: Option<ExternalEmail>, 240 pub provider_email_verified: bool, 241 pub created_at: DateTime<Utc>, 242 pub expires_at: DateTime<Utc>, ··· 279 state: &str, 280 request_uri: &str, 281 provider: SsoProviderType, 282 + action: SsoAction, 283 nonce: Option<&str>, 284 code_verifier: Option<&str>, 285 did: Option<&Did>,
+100 -34
crates/tranquil-db-traits/src/user.rs
··· 1 use async_trait::async_trait; 2 use chrono::{DateTime, Utc}; 3 use tranquil_types::{Did, Handle}; 4 use uuid::Uuid; 5 6 - use crate::{CommsChannel, DbError, SsoProviderType}; 7 8 #[derive(Debug, Clone)] 9 pub struct UserRow { ··· 62 pub preferred_comms_channel: CommsChannel, 63 pub deactivated_at: Option<DateTime<Utc>>, 64 pub takedown_ref: Option<String>, 65 - pub email_verified: bool, 66 - pub discord_verified: bool, 67 - pub telegram_verified: bool, 68 - pub signal_verified: bool, 69 - pub account_type: String, 70 } 71 72 #[derive(Debug, Clone)] ··· 74 pub id: Uuid, 75 pub two_factor_enabled: bool, 76 pub preferred_comms_channel: CommsChannel, 77 - pub email_verified: bool, 78 - pub discord_verified: bool, 79 - pub telegram_verified: bool, 80 - pub signal_verified: bool, 81 } 82 83 #[async_trait] ··· 202 did: &Did, 203 ) -> Result<Option<UserIdHandleEmail>, DbError>; 204 205 - async fn update_preferred_comms_channel(&self, did: &Did, channel: &str) 206 - -> Result<(), DbError>; 207 208 async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError>; 209 ··· 292 293 async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>; 294 295 async fn upsert_totp_secret( 296 &self, 297 did: &Did, ··· 560 pub struct UserCommsPrefs { 561 pub email: Option<String>, 562 pub handle: Handle, 563 - pub preferred_channel: String, 564 pub preferred_locale: Option<String>, 565 } 566 ··· 611 pub password_hash: Option<String>, 612 pub deactivated_at: Option<DateTime<Utc>>, 613 pub takedown_ref: Option<String>, 614 - pub email_verified: bool, 615 - pub discord_verified: bool, 616 - pub telegram_verified: bool, 617 - pub signal_verified: bool, 618 } 619 620 #[derive(Debug, Clone)] 621 pub struct NotificationPrefs { 622 pub email: String, 623 - pub preferred_channel: String, 624 pub discord_id: Option<String>, 625 pub discord_verified: bool, 626 pub telegram_username: Option<String>, ··· 641 pub id: Uuid, 642 pub handle: Handle, 643 pub email: Option<String>, 644 - pub email_verified: bool, 645 - pub discord_verified: bool, 646 - pub telegram_verified: bool, 647 - pub signal_verified: bool, 648 } 649 650 #[derive(Debug, Clone)] ··· 675 pub verified: bool, 676 } 677 678 #[derive(Debug, Clone)] 679 pub struct StoredBackupCode { 680 pub id: Uuid, ··· 685 pub struct UserSessionInfo { 686 pub handle: Handle, 687 pub email: Option<String>, 688 - pub email_verified: bool, 689 pub is_admin: bool, 690 pub deactivated_at: Option<DateTime<Utc>>, 691 pub takedown_ref: Option<String>, 692 pub preferred_locale: Option<String>, 693 pub preferred_comms_channel: CommsChannel, 694 - pub discord_verified: bool, 695 - pub telegram_verified: bool, 696 - pub signal_verified: bool, 697 pub migrated_to_pds: Option<String>, 698 pub migrated_at: Option<DateTime<Utc>>, 699 } ··· 713 pub email: Option<String>, 714 pub deactivated_at: Option<DateTime<Utc>>, 715 pub takedown_ref: Option<String>, 716 - pub email_verified: bool, 717 - pub discord_verified: bool, 718 - pub telegram_verified: bool, 719 - pub signal_verified: bool, 720 pub allow_legacy_login: bool, 721 pub migrated_to_pds: Option<String>, 722 pub preferred_comms_channel: CommsChannel, ··· 748 pub discord_id: Option<String>, 749 pub telegram_username: Option<String>, 750 pub signal_number: Option<String>, 751 - pub email_verified: bool, 752 - pub discord_verified: bool, 753 - pub telegram_verified: bool, 754 - pub signal_verified: bool, 755 } 756 757 #[derive(Debug, Clone)]
··· 1 use async_trait::async_trait; 2 use chrono::{DateTime, Utc}; 3 + use serde::{Deserialize, Serialize}; 4 use tranquil_types::{Did, Handle}; 5 use uuid::Uuid; 6 7 + use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType}; 8 + 9 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 10 + #[sqlx(type_name = "account_type", rename_all = "snake_case")] 11 + pub enum AccountType { 12 + Personal, 13 + Delegated, 14 + } 15 + 16 + impl AccountType { 17 + pub fn is_delegated(&self) -> bool { 18 + matches!(self, Self::Delegated) 19 + } 20 + } 21 22 #[derive(Debug, Clone)] 23 pub struct UserRow { ··· 76 pub preferred_comms_channel: CommsChannel, 77 pub deactivated_at: Option<DateTime<Utc>>, 78 pub takedown_ref: Option<String>, 79 + pub channel_verification: ChannelVerificationStatus, 80 + pub account_type: AccountType, 81 } 82 83 #[derive(Debug, Clone)] ··· 85 pub id: Uuid, 86 pub two_factor_enabled: bool, 87 pub preferred_comms_channel: CommsChannel, 88 + pub channel_verification: ChannelVerificationStatus, 89 } 90 91 #[async_trait] ··· 210 did: &Did, 211 ) -> Result<Option<UserIdHandleEmail>, DbError>; 212 213 + async fn update_preferred_comms_channel( 214 + &self, 215 + did: &Did, 216 + channel: CommsChannel, 217 + ) -> Result<(), DbError>; 218 219 async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError>; 220 ··· 303 304 async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>; 305 306 + async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError>; 307 + 308 async fn upsert_totp_secret( 309 &self, 310 did: &Did, ··· 573 pub struct UserCommsPrefs { 574 pub email: Option<String>, 575 pub handle: Handle, 576 + pub preferred_channel: CommsChannel, 577 pub preferred_locale: Option<String>, 578 } 579 ··· 624 pub password_hash: Option<String>, 625 pub deactivated_at: Option<DateTime<Utc>>, 626 pub takedown_ref: Option<String>, 627 + pub channel_verification: ChannelVerificationStatus, 628 } 629 630 #[derive(Debug, Clone)] 631 pub struct NotificationPrefs { 632 pub email: String, 633 + pub preferred_channel: CommsChannel, 634 pub discord_id: Option<String>, 635 pub discord_verified: bool, 636 pub telegram_username: Option<String>, ··· 651 pub id: Uuid, 652 pub handle: Handle, 653 pub email: Option<String>, 654 + pub channel_verification: ChannelVerificationStatus, 655 } 656 657 #[derive(Debug, Clone)] ··· 682 pub verified: bool, 683 } 684 685 + #[derive(Debug, Clone)] 686 + pub struct VerifiedTotpRecord { 687 + pub secret_encrypted: Vec<u8>, 688 + pub encryption_version: i32, 689 + } 690 + 691 + #[derive(Debug, Clone)] 692 + pub struct UnverifiedTotpRecord { 693 + pub secret_encrypted: Vec<u8>, 694 + pub encryption_version: i32, 695 + } 696 + 697 + #[derive(Debug, Clone)] 698 + pub enum TotpRecordState { 699 + Verified(VerifiedTotpRecord), 700 + Unverified(UnverifiedTotpRecord), 701 + } 702 + 703 + impl TotpRecordState { 704 + pub fn is_verified(&self) -> bool { 705 + matches!(self, Self::Verified(_)) 706 + } 707 + 708 + pub fn as_verified(&self) -> Option<&VerifiedTotpRecord> { 709 + match self { 710 + Self::Verified(r) => Some(r), 711 + Self::Unverified(_) => None, 712 + } 713 + } 714 + 715 + pub fn as_unverified(&self) -> Option<&UnverifiedTotpRecord> { 716 + match self { 717 + Self::Unverified(r) => Some(r), 718 + Self::Verified(_) => None, 719 + } 720 + } 721 + 722 + pub fn into_verified(self) -> Option<VerifiedTotpRecord> { 723 + match self { 724 + Self::Verified(r) => Some(r), 725 + Self::Unverified(_) => None, 726 + } 727 + } 728 + 729 + pub fn into_unverified(self) -> Option<UnverifiedTotpRecord> { 730 + match self { 731 + Self::Unverified(r) => Some(r), 732 + Self::Verified(_) => None, 733 + } 734 + } 735 + } 736 + 737 + impl From<TotpRecord> for TotpRecordState { 738 + fn from(record: TotpRecord) -> Self { 739 + if record.verified { 740 + Self::Verified(VerifiedTotpRecord { 741 + secret_encrypted: record.secret_encrypted, 742 + encryption_version: record.encryption_version, 743 + }) 744 + } else { 745 + Self::Unverified(UnverifiedTotpRecord { 746 + secret_encrypted: record.secret_encrypted, 747 + encryption_version: record.encryption_version, 748 + }) 749 + } 750 + } 751 + } 752 + 753 #[derive(Debug, Clone)] 754 pub struct StoredBackupCode { 755 pub id: Uuid, ··· 760 pub struct UserSessionInfo { 761 pub handle: Handle, 762 pub email: Option<String>, 763 pub is_admin: bool, 764 pub deactivated_at: Option<DateTime<Utc>>, 765 pub takedown_ref: Option<String>, 766 pub preferred_locale: Option<String>, 767 pub preferred_comms_channel: CommsChannel, 768 + pub channel_verification: ChannelVerificationStatus, 769 pub migrated_to_pds: Option<String>, 770 pub migrated_at: Option<DateTime<Utc>>, 771 } ··· 785 pub email: Option<String>, 786 pub deactivated_at: Option<DateTime<Utc>>, 787 pub takedown_ref: Option<String>, 788 + pub channel_verification: ChannelVerificationStatus, 789 pub allow_legacy_login: bool, 790 pub migrated_to_pds: Option<String>, 791 pub preferred_comms_channel: CommsChannel, ··· 817 pub discord_id: Option<String>, 818 pub telegram_username: Option<String>, 819 pub signal_number: Option<String>, 820 + pub channel_verification: ChannelVerificationStatus, 821 } 822 823 #[derive(Debug, Clone)]
+9 -9
crates/tranquil-db/src/postgres/delegation.rs
··· 1 use async_trait::async_trait; 2 use sqlx::PgPool; 3 use tranquil_db_traits::{ 4 - AuditLogEntry, ControllerInfo, DbError, DelegatedAccountInfo, DelegationActionType, 5 DelegationGrant, DelegationRepository, 6 }; 7 use tranquil_types::Did; ··· 80 &self, 81 delegated_did: &Did, 82 controller_did: &Did, 83 - granted_scopes: &str, 84 granted_by: &Did, 85 ) -> Result<Uuid, DbError> { 86 let id = sqlx::query_scalar!( ··· 91 "#, 92 delegated_did.as_str(), 93 controller_did.as_str(), 94 - granted_scopes, 95 granted_by.as_str() 96 ) 97 .fetch_one(&self.pool) ··· 128 &self, 129 delegated_did: &Did, 130 controller_did: &Did, 131 - new_scopes: &str, 132 ) -> Result<bool, DbError> { 133 let result = sqlx::query!( 134 r#" ··· 136 SET granted_scopes = $1 137 WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL 138 "#, 139 - new_scopes, 140 delegated_did.as_str(), 141 controller_did.as_str() 142 ) ··· 170 id: r.id, 171 delegated_did: r.delegated_did.into(), 172 controller_did: r.controller_did.into(), 173 - granted_scopes: r.granted_scopes, 174 granted_at: r.granted_at, 175 granted_by: r.granted_by.into(), 176 revoked_at: r.revoked_at, ··· 206 .map(|r| ControllerInfo { 207 did: r.did.into(), 208 handle: r.handle.into(), 209 - granted_scopes: r.granted_scopes, 210 granted_at: r.granted_at, 211 is_active: r.is_active, 212 }) ··· 243 .map(|r| DelegatedAccountInfo { 244 did: r.did.into(), 245 handle: r.handle.into(), 246 - granted_scopes: r.granted_scopes, 247 granted_at: r.granted_at, 248 }) 249 .collect()) ··· 280 .map(|r| ControllerInfo { 281 did: r.did.into(), 282 handle: r.handle.into(), 283 - granted_scopes: r.granted_scopes, 284 granted_at: r.granted_at, 285 is_active: r.is_active, 286 })
··· 1 use async_trait::async_trait; 2 use sqlx::PgPool; 3 use tranquil_db_traits::{ 4 + AuditLogEntry, ControllerInfo, DbError, DbScope, DelegatedAccountInfo, DelegationActionType, 5 DelegationGrant, DelegationRepository, 6 }; 7 use tranquil_types::Did; ··· 80 &self, 81 delegated_did: &Did, 82 controller_did: &Did, 83 + granted_scopes: &DbScope, 84 granted_by: &Did, 85 ) -> Result<Uuid, DbError> { 86 let id = sqlx::query_scalar!( ··· 91 "#, 92 delegated_did.as_str(), 93 controller_did.as_str(), 94 + granted_scopes.as_str(), 95 granted_by.as_str() 96 ) 97 .fetch_one(&self.pool) ··· 128 &self, 129 delegated_did: &Did, 130 controller_did: &Did, 131 + new_scopes: &DbScope, 132 ) -> Result<bool, DbError> { 133 let result = sqlx::query!( 134 r#" ··· 136 SET granted_scopes = $1 137 WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL 138 "#, 139 + new_scopes.as_str(), 140 delegated_did.as_str(), 141 controller_did.as_str() 142 ) ··· 170 id: r.id, 171 delegated_did: r.delegated_did.into(), 172 controller_did: r.controller_did.into(), 173 + granted_scopes: DbScope::from_db_unchecked(r.granted_scopes), 174 granted_at: r.granted_at, 175 granted_by: r.granted_by.into(), 176 revoked_at: r.revoked_at, ··· 206 .map(|r| ControllerInfo { 207 did: r.did.into(), 208 handle: r.handle.into(), 209 + granted_scopes: DbScope::from_db_unchecked(r.granted_scopes), 210 granted_at: r.granted_at, 211 is_active: r.is_active, 212 }) ··· 243 .map(|r| DelegatedAccountInfo { 244 did: r.did.into(), 245 handle: r.handle.into(), 246 + granted_scopes: DbScope::from_db_unchecked(r.granted_scopes), 247 granted_at: r.granted_at, 248 }) 249 .collect()) ··· 280 .map(|r| ControllerInfo { 281 did: r.did.into(), 282 handle: r.handle.into(), 283 + granted_scopes: DbScope::from_db_unchecked(r.granted_scopes), 284 granted_at: r.granted_at, 285 is_active: r.is_active, 286 })
+34 -18
crates/tranquil-db/src/postgres/infra.rs
··· 3 use sqlx::PgPool; 4 use tranquil_db_traits::{ 5 AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest, 6 - InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, 7 - NotificationHistoryRow, QueuedComms, ReservedSigningKey, 8 }; 9 use tranquil_types::{CidLink, Did, Handle}; 10 use uuid::Uuid; ··· 182 Ok(result) 183 } 184 185 - async fn is_invite_code_valid(&self, code: &str) -> Result<bool, DbError> { 186 - let result = sqlx::query_scalar!( 187 - r#"SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as "valid!" FROM invite_codes WHERE code = $1"#, 188 code 189 ) 190 .fetch_optional(&self.pool) 191 .await 192 - .map_err(map_sqlx_error)?; 193 194 - Ok(result.unwrap_or(false)) 195 } 196 197 - async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError> { 198 sqlx::query!( 199 "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 200 - code 201 ) 202 .execute(&self.pool) 203 .await ··· 206 Ok(()) 207 } 208 209 - async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError> { 210 sqlx::query!( 211 "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 212 - code, 213 used_by_user 214 ) 215 .execute(&self.pool) ··· 245 .map(|r| InviteCodeInfo { 246 code: r.code, 247 available_uses: r.available_uses, 248 - disabled: r.disabled.unwrap_or(false), 249 for_account: Some(Did::from(r.for_account)), 250 created_at: r.created_at, 251 created_by: None, ··· 422 .map(|r| InviteCodeInfo { 423 code: r.code, 424 available_uses: r.available_uses, 425 - disabled: r.disabled.unwrap_or(false), 426 for_account: Some(Did::from(r.for_account)), 427 created_at: r.created_at, 428 created_by: Some(Did::from(r.created_by)), ··· 445 Ok(result.map(|r| InviteCodeInfo { 446 code: r.code, 447 available_uses: r.available_uses, 448 - disabled: r.disabled.unwrap_or(false), 449 for_account: Some(Did::from(r.for_account)), 450 created_at: r.created_at, 451 created_by: Some(Did::from(r.created_by)), ··· 476 InviteCodeInfo { 477 code: r.code, 478 available_uses: r.available_uses, 479 - disabled: r.disabled.unwrap_or(false), 480 for_account: Some(Did::from(r.for_account)), 481 created_at: r.created_at, 482 created_by: Some(Did::from(r.created_by)), ··· 841 r#" 842 SELECT 843 created_at, 844 - channel as "channel: String", 845 - comms_type as "comms_type: String", 846 - status as "status: String", 847 subject, 848 body 849 FROM comms_queue
··· 3 use sqlx::PgPool; 4 use tranquil_db_traits::{ 5 AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest, 6 + InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, 7 + InviteCodeState, InviteCodeUse, NotificationHistoryRow, QueuedComms, ReservedSigningKey, 8 + ValidatedInviteCode, 9 }; 10 use tranquil_types::{CidLink, Did, Handle}; 11 use uuid::Uuid; ··· 183 Ok(result) 184 } 185 186 + async fn validate_invite_code<'a>( 187 + &self, 188 + code: &'a str, 189 + ) -> Result<ValidatedInviteCode<'a>, InviteCodeError> { 190 + let result = sqlx::query!( 191 + r#"SELECT available_uses, COALESCE(disabled, false) as "disabled!" FROM invite_codes WHERE code = $1"#, 192 code 193 ) 194 .fetch_optional(&self.pool) 195 .await 196 + .map_err(|e| InviteCodeError::DatabaseError(map_sqlx_error(e)))?; 197 198 + match result { 199 + None => Err(InviteCodeError::NotFound), 200 + Some(row) if row.disabled => Err(InviteCodeError::Disabled), 201 + Some(row) if row.available_uses <= 0 => Err(InviteCodeError::ExhaustedUses), 202 + Some(_) => Ok(ValidatedInviteCode::new_validated(code)), 203 + } 204 } 205 206 + async fn decrement_invite_code_uses( 207 + &self, 208 + code: &ValidatedInviteCode<'_>, 209 + ) -> Result<(), DbError> { 210 sqlx::query!( 211 "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 212 + code.code() 213 ) 214 .execute(&self.pool) 215 .await ··· 218 Ok(()) 219 } 220 221 + async fn record_invite_code_use( 222 + &self, 223 + code: &ValidatedInviteCode<'_>, 224 + used_by_user: Uuid, 225 + ) -> Result<(), DbError> { 226 sqlx::query!( 227 "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 228 + code.code(), 229 used_by_user 230 ) 231 .execute(&self.pool) ··· 261 .map(|r| InviteCodeInfo { 262 code: r.code, 263 available_uses: r.available_uses, 264 + state: InviteCodeState::from(r.disabled), 265 for_account: Some(Did::from(r.for_account)), 266 created_at: r.created_at, 267 created_by: None, ··· 438 .map(|r| InviteCodeInfo { 439 code: r.code, 440 available_uses: r.available_uses, 441 + state: InviteCodeState::from(r.disabled), 442 for_account: Some(Did::from(r.for_account)), 443 created_at: r.created_at, 444 created_by: Some(Did::from(r.created_by)), ··· 461 Ok(result.map(|r| InviteCodeInfo { 462 code: r.code, 463 available_uses: r.available_uses, 464 + state: InviteCodeState::from(r.disabled), 465 for_account: Some(Did::from(r.for_account)), 466 created_at: r.created_at, 467 created_by: Some(Did::from(r.created_by)), ··· 492 InviteCodeInfo { 493 code: r.code, 494 available_uses: r.available_uses, 495 + state: InviteCodeState::from(r.disabled), 496 for_account: Some(Did::from(r.for_account)), 497 created_at: r.created_at, 498 created_by: Some(Did::from(r.created_by)), ··· 857 r#" 858 SELECT 859 created_at, 860 + channel as "channel: CommsChannel", 861 + comms_type as "comms_type: CommsType", 862 + status as "status: CommsStatus", 863 subject, 864 body 865 FROM comms_queue
+146 -107
crates/tranquil-db/src/postgres/repo.rs
··· 2 use chrono::{DateTime, Utc}; 3 use sqlx::PgPool; 4 use tranquil_db_traits::{ 5 - BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo, ImportBlock, 6 - ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo, RepoInfo, 7 - RepoListItem, RepoRepository, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, 8 - UserWithoutBlocks, 9 }; 10 use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey}; 11 use uuid::Uuid; ··· 21 seq: i64, 22 did: String, 23 created_at: DateTime<Utc>, 24 - event_type: String, 25 commit_cid: Option<String>, 26 prev_cid: Option<String>, 27 prev_data_cid: Option<String>, ··· 627 Ok(rows.into_iter().map(|(cid,)| cid).collect()) 628 } 629 630 - async fn insert_commit_event(&self, data: &CommitEventData) -> Result<i64, DbError> { 631 let seq = sqlx::query_scalar!( 632 r#" 633 INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev) ··· 635 RETURNING seq 636 "#, 637 data.did.as_str(), 638 - data.event_type, 639 data.commit_cid.as_ref().map(|c| c.as_str()), 640 data.prev_cid.as_ref().map(|c| c.as_str()), 641 data.ops, ··· 648 .await 649 .map_err(map_sqlx_error)?; 650 651 - Ok(seq) 652 } 653 654 async fn insert_identity_event( 655 &self, 656 did: &Did, 657 handle: Option<&Handle>, 658 - ) -> Result<i64, DbError> { 659 let handle_str = handle.map(|h| h.as_str()); 660 let seq = sqlx::query_scalar!( 661 r#" ··· 675 .await 676 .map_err(map_sqlx_error)?; 677 678 - Ok(seq) 679 } 680 681 async fn insert_account_event( 682 &self, 683 did: &Did, 684 - active: bool, 685 - status: Option<&str>, 686 - ) -> Result<i64, DbError> { 687 let seq = sqlx::query_scalar!( 688 r#" 689 INSERT INTO repo_seq (did, event_type, active, status) ··· 692 "#, 693 did.as_str(), 694 active, 695 - status 696 ) 697 .fetch_one(&self.pool) 698 .await ··· 703 .await 704 .map_err(map_sqlx_error)?; 705 706 - Ok(seq) 707 } 708 709 async fn insert_sync_event( ··· 711 did: &Did, 712 commit_cid: &CidLink, 713 rev: Option<&str>, 714 - ) -> Result<i64, DbError> { 715 let seq = sqlx::query_scalar!( 716 r#" 717 INSERT INTO repo_seq (did, event_type, commit_cid, rev) ··· 731 .await 732 .map_err(map_sqlx_error)?; 733 734 - Ok(seq) 735 } 736 737 async fn insert_genesis_commit_event( ··· 740 commit_cid: &CidLink, 741 mst_root_cid: &CidLink, 742 rev: &str, 743 - ) -> Result<i64, DbError> { 744 let ops = serde_json::json!([]); 745 let blobs: Vec<String> = vec![]; 746 let blocks_cids: Vec<String> = vec![mst_root_cid.to_string(), commit_cid.to_string()]; ··· 769 .await 770 .map_err(map_sqlx_error)?; 771 772 - Ok(seq) 773 } 774 775 async fn update_seq_blocks_cids( 776 &self, 777 - seq: i64, 778 blocks_cids: &[String], 779 ) -> Result<(), DbError> { 780 sqlx::query!( 781 "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2", 782 blocks_cids, 783 - seq 784 ) 785 .execute(&self.pool) 786 .await ··· 789 Ok(()) 790 } 791 792 - async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError> { 793 sqlx::query!( 794 "DELETE FROM repo_seq WHERE did = $1 AND seq != $2", 795 did.as_str(), 796 - keep_seq 797 ) 798 .execute(&self.pool) 799 .await ··· 802 Ok(()) 803 } 804 805 - async fn get_max_seq(&self) -> Result<i64, DbError> { 806 let seq = sqlx::query_scalar!(r#"SELECT COALESCE(MAX(seq), 0) as "max!" FROM repo_seq"#) 807 .fetch_one(&self.pool) 808 .await 809 .map_err(map_sqlx_error)?; 810 811 - Ok(seq) 812 } 813 814 - async fn get_min_seq_since(&self, since: DateTime<Utc>) -> Result<Option<i64>, DbError> { 815 let seq = sqlx::query_scalar!( 816 "SELECT MIN(seq) FROM repo_seq WHERE created_at >= $1", 817 since ··· 820 .await 821 .map_err(map_sqlx_error)?; 822 823 - Ok(seq) 824 } 825 826 async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError> { ··· 846 847 async fn get_events_since_seq( 848 &self, 849 - since_seq: i64, 850 limit: Option<i64>, 851 ) -> Result<Vec<SequencedEvent>, DbError> { 852 - let map_row = |r: SequencedEventRow| SequencedEvent { 853 - seq: r.seq, 854 - did: Did::from(r.did), 855 - created_at: r.created_at, 856 - event_type: r.event_type, 857 - commit_cid: r.commit_cid.map(CidLink::from), 858 - prev_cid: r.prev_cid.map(CidLink::from), 859 - prev_data_cid: r.prev_data_cid.map(CidLink::from), 860 - ops: r.ops, 861 - blobs: r.blobs, 862 - blocks_cids: r.blocks_cids, 863 - handle: r.handle.map(Handle::from), 864 - active: r.active, 865 - status: r.status, 866 - rev: r.rev, 867 }; 868 match limit { 869 Some(lim) => { 870 let rows = sqlx::query_as!( 871 SequencedEventRow, 872 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 873 ops, blobs, blocks_cids, handle, active, status, rev 874 FROM repo_seq 875 WHERE seq > $1 876 ORDER BY seq ASC 877 LIMIT $2"#, 878 - since_seq, 879 lim 880 ) 881 .fetch_all(&self.pool) ··· 886 None => { 887 let rows = sqlx::query_as!( 888 SequencedEventRow, 889 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 890 ops, blobs, blocks_cids, handle, active, status, rev 891 FROM repo_seq 892 WHERE seq > $1 893 ORDER BY seq ASC"#, 894 - since_seq 895 ) 896 .fetch_all(&self.pool) 897 .await ··· 903 904 async fn get_events_in_seq_range( 905 &self, 906 - start_seq: i64, 907 - end_seq: i64, 908 ) -> Result<Vec<SequencedEvent>, DbError> { 909 let rows = sqlx::query!( 910 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 911 ops, blobs, blocks_cids, handle, active, status, rev 912 FROM repo_seq 913 WHERE seq > $1 AND seq < $2 914 ORDER BY seq ASC"#, 915 - start_seq, 916 - end_seq 917 ) 918 .fetch_all(&self.pool) 919 .await 920 .map_err(map_sqlx_error)?; 921 Ok(rows 922 .into_iter() 923 - .map(|r| SequencedEvent { 924 - seq: r.seq, 925 - did: Did::from(r.did), 926 - created_at: r.created_at, 927 - event_type: r.event_type, 928 - commit_cid: r.commit_cid.map(CidLink::from), 929 - prev_cid: r.prev_cid.map(CidLink::from), 930 - prev_data_cid: r.prev_data_cid.map(CidLink::from), 931 - ops: r.ops, 932 - blobs: r.blobs, 933 - blocks_cids: r.blocks_cids, 934 - handle: r.handle.map(Handle::from), 935 - active: r.active, 936 - status: r.status, 937 - rev: r.rev, 938 }) 939 .collect()) 940 } 941 942 - async fn get_event_by_seq(&self, seq: i64) -> Result<Option<SequencedEvent>, DbError> { 943 let row = sqlx::query!( 944 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 945 ops, blobs, blocks_cids, handle, active, status, rev 946 FROM repo_seq 947 WHERE seq = $1"#, 948 - seq 949 ) 950 .fetch_optional(&self.pool) 951 .await 952 .map_err(map_sqlx_error)?; 953 - Ok(row.map(|r| SequencedEvent { 954 - seq: r.seq, 955 - did: Did::from(r.did), 956 - created_at: r.created_at, 957 - event_type: r.event_type, 958 - commit_cid: r.commit_cid.map(CidLink::from), 959 - prev_cid: r.prev_cid.map(CidLink::from), 960 - prev_data_cid: r.prev_data_cid.map(CidLink::from), 961 - ops: r.ops, 962 - blobs: r.blobs, 963 - blocks_cids: r.blocks_cids, 964 - handle: r.handle.map(Handle::from), 965 - active: r.active, 966 - status: r.status, 967 - rev: r.rev, 968 })) 969 } 970 971 async fn get_events_since_cursor( 972 &self, 973 - cursor: i64, 974 limit: i64, 975 ) -> Result<Vec<SequencedEvent>, DbError> { 976 let rows = sqlx::query!( 977 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 978 ops, blobs, blocks_cids, handle, active, status, rev 979 FROM repo_seq 980 WHERE seq > $1 981 ORDER BY seq ASC 982 LIMIT $2"#, 983 - cursor, 984 limit 985 ) 986 .fetch_all(&self.pool) ··· 988 .map_err(map_sqlx_error)?; 989 Ok(rows 990 .into_iter() 991 - .map(|r| SequencedEvent { 992 - seq: r.seq, 993 - did: Did::from(r.did), 994 - created_at: r.created_at, 995 - event_type: r.event_type, 996 - commit_cid: r.commit_cid.map(CidLink::from), 997 - prev_cid: r.prev_cid.map(CidLink::from), 998 - prev_data_cid: r.prev_data_cid.map(CidLink::from), 999 - ops: r.ops, 1000 - blobs: r.blobs, 1001 - blocks_cids: r.blocks_cids, 1002 - handle: r.handle.map(Handle::from), 1003 - active: r.active, 1004 - status: r.status, 1005 - rev: r.rev, 1006 }) 1007 .collect()) 1008 } ··· 1079 Ok(cid.map(CidLink::from)) 1080 } 1081 1082 - async fn notify_update(&self, seq: i64) -> Result<(), DbError> { 1083 - sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) 1084 .execute(&self.pool) 1085 .await 1086 .map_err(map_sqlx_error)?; ··· 1329 "#, 1330 ) 1331 .bind(&event.did) 1332 - .bind(&event.event_type) 1333 .bind(&event.commit_cid) 1334 .bind(&event.prev_cid) 1335 .bind(&event.ops) ··· 1375 Ok(rows 1376 .into_iter() 1377 .map(|r| BrokenGenesisCommit { 1378 - seq: r.seq, 1379 did: Did::from(r.did), 1380 commit_cid: r.commit_cid.map(CidLink::from), 1381 })
··· 2 use chrono::{DateTime, Utc}; 3 use sqlx::PgPool; 4 use tranquil_db_traits::{ 5 + AccountStatus, BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo, 6 + ImportBlock, ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo, 7 + RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev, SequenceNumber, 8 + SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, 9 }; 10 use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey}; 11 use uuid::Uuid; ··· 21 seq: i64, 22 did: String, 23 created_at: DateTime<Utc>, 24 + event_type: RepoEventType, 25 commit_cid: Option<String>, 26 prev_cid: Option<String>, 27 prev_data_cid: Option<String>, ··· 627 Ok(rows.into_iter().map(|(cid,)| cid).collect()) 628 } 629 630 + async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError> { 631 let seq = sqlx::query_scalar!( 632 r#" 633 INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev) ··· 635 RETURNING seq 636 "#, 637 data.did.as_str(), 638 + data.event_type.as_str(), 639 data.commit_cid.as_ref().map(|c| c.as_str()), 640 data.prev_cid.as_ref().map(|c| c.as_str()), 641 data.ops, ··· 648 .await 649 .map_err(map_sqlx_error)?; 650 651 + Ok(seq.into()) 652 } 653 654 async fn insert_identity_event( 655 &self, 656 did: &Did, 657 handle: Option<&Handle>, 658 + ) -> Result<SequenceNumber, DbError> { 659 let handle_str = handle.map(|h| h.as_str()); 660 let seq = sqlx::query_scalar!( 661 r#" ··· 675 .await 676 .map_err(map_sqlx_error)?; 677 678 + Ok(seq.into()) 679 } 680 681 async fn insert_account_event( 682 &self, 683 did: &Did, 684 + status: AccountStatus, 685 + ) -> Result<SequenceNumber, DbError> { 686 + let active = status.is_active(); 687 + let status_str = status.for_firehose(); 688 let seq = sqlx::query_scalar!( 689 r#" 690 INSERT INTO repo_seq (did, event_type, active, status) ··· 693 "#, 694 did.as_str(), 695 active, 696 + status_str 697 ) 698 .fetch_one(&self.pool) 699 .await ··· 704 .await 705 .map_err(map_sqlx_error)?; 706 707 + Ok(seq.into()) 708 } 709 710 async fn insert_sync_event( ··· 712 did: &Did, 713 commit_cid: &CidLink, 714 rev: Option<&str>, 715 + ) -> Result<SequenceNumber, DbError> { 716 let seq = sqlx::query_scalar!( 717 r#" 718 INSERT INTO repo_seq (did, event_type, commit_cid, rev) ··· 732 .await 733 .map_err(map_sqlx_error)?; 734 735 + Ok(seq.into()) 736 } 737 738 async fn insert_genesis_commit_event( ··· 741 commit_cid: &CidLink, 742 mst_root_cid: &CidLink, 743 rev: &str, 744 + ) -> Result<SequenceNumber, DbError> { 745 let ops = serde_json::json!([]); 746 let blobs: Vec<String> = vec![]; 747 let blocks_cids: Vec<String> = vec![mst_root_cid.to_string(), commit_cid.to_string()]; ··· 770 .await 771 .map_err(map_sqlx_error)?; 772 773 + Ok(seq.into()) 774 } 775 776 async fn update_seq_blocks_cids( 777 &self, 778 + seq: SequenceNumber, 779 blocks_cids: &[String], 780 ) -> Result<(), DbError> { 781 sqlx::query!( 782 "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2", 783 blocks_cids, 784 + seq.as_i64() 785 ) 786 .execute(&self.pool) 787 .await ··· 790 Ok(()) 791 } 792 793 + async fn delete_sequences_except( 794 + &self, 795 + did: &Did, 796 + keep_seq: SequenceNumber, 797 + ) -> Result<(), DbError> { 798 sqlx::query!( 799 "DELETE FROM repo_seq WHERE did = $1 AND seq != $2", 800 did.as_str(), 801 + keep_seq.as_i64() 802 ) 803 .execute(&self.pool) 804 .await ··· 807 Ok(()) 808 } 809 810 + async fn get_max_seq(&self) -> Result<SequenceNumber, DbError> { 811 let seq = sqlx::query_scalar!(r#"SELECT COALESCE(MAX(seq), 0) as "max!" FROM repo_seq"#) 812 .fetch_one(&self.pool) 813 .await 814 .map_err(map_sqlx_error)?; 815 816 + Ok(seq.into()) 817 } 818 819 + async fn get_min_seq_since( 820 + &self, 821 + since: DateTime<Utc>, 822 + ) -> Result<Option<SequenceNumber>, DbError> { 823 let seq = sqlx::query_scalar!( 824 "SELECT MIN(seq) FROM repo_seq WHERE created_at >= $1", 825 since ··· 828 .await 829 .map_err(map_sqlx_error)?; 830 831 + Ok(seq.map(SequenceNumber::from)) 832 } 833 834 async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError> { ··· 854 855 async fn get_events_since_seq( 856 &self, 857 + since_seq: SequenceNumber, 858 limit: Option<i64>, 859 ) -> Result<Vec<SequencedEvent>, DbError> { 860 + let map_row = |r: SequencedEventRow| { 861 + let status = r 862 + .status 863 + .as_deref() 864 + .and_then(AccountStatus::parse) 865 + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); 866 + SequencedEvent { 867 + seq: r.seq.into(), 868 + did: Did::from(r.did), 869 + created_at: r.created_at, 870 + event_type: r.event_type, 871 + commit_cid: r.commit_cid.map(CidLink::from), 872 + prev_cid: r.prev_cid.map(CidLink::from), 873 + prev_data_cid: r.prev_data_cid.map(CidLink::from), 874 + ops: r.ops, 875 + blobs: r.blobs, 876 + blocks_cids: r.blocks_cids, 877 + handle: r.handle.map(Handle::from), 878 + active: r.active, 879 + status, 880 + rev: r.rev, 881 + } 882 }; 883 match limit { 884 Some(lim) => { 885 let rows = sqlx::query_as!( 886 SequencedEventRow, 887 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 888 ops, blobs, blocks_cids, handle, active, status, rev 889 FROM repo_seq 890 WHERE seq > $1 891 ORDER BY seq ASC 892 LIMIT $2"#, 893 + since_seq.as_i64(), 894 lim 895 ) 896 .fetch_all(&self.pool) ··· 901 None => { 902 let rows = sqlx::query_as!( 903 SequencedEventRow, 904 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 905 ops, blobs, blocks_cids, handle, active, status, rev 906 FROM repo_seq 907 WHERE seq > $1 908 ORDER BY seq ASC"#, 909 + since_seq.as_i64() 910 ) 911 .fetch_all(&self.pool) 912 .await ··· 918 919 async fn get_events_in_seq_range( 920 &self, 921 + start_seq: SequenceNumber, 922 + end_seq: SequenceNumber, 923 ) -> Result<Vec<SequencedEvent>, DbError> { 924 let rows = sqlx::query!( 925 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 926 ops, blobs, blocks_cids, handle, active, status, rev 927 FROM repo_seq 928 WHERE seq > $1 AND seq < $2 929 ORDER BY seq ASC"#, 930 + start_seq.as_i64(), 931 + end_seq.as_i64() 932 ) 933 .fetch_all(&self.pool) 934 .await 935 .map_err(map_sqlx_error)?; 936 Ok(rows 937 .into_iter() 938 + .map(|r| { 939 + let status = r 940 + .status 941 + .as_deref() 942 + .and_then(AccountStatus::parse) 943 + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); 944 + SequencedEvent { 945 + seq: r.seq.into(), 946 + did: Did::from(r.did), 947 + created_at: r.created_at, 948 + event_type: r.event_type, 949 + commit_cid: r.commit_cid.map(CidLink::from), 950 + prev_cid: r.prev_cid.map(CidLink::from), 951 + prev_data_cid: r.prev_data_cid.map(CidLink::from), 952 + ops: r.ops, 953 + blobs: r.blobs, 954 + blocks_cids: r.blocks_cids, 955 + handle: r.handle.map(Handle::from), 956 + active: r.active, 957 + status, 958 + rev: r.rev, 959 + } 960 }) 961 .collect()) 962 } 963 964 + async fn get_event_by_seq( 965 + &self, 966 + seq: SequenceNumber, 967 + ) -> Result<Option<SequencedEvent>, DbError> { 968 let row = sqlx::query!( 969 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 970 ops, blobs, blocks_cids, handle, active, status, rev 971 FROM repo_seq 972 WHERE seq = $1"#, 973 + seq.as_i64() 974 ) 975 .fetch_optional(&self.pool) 976 .await 977 .map_err(map_sqlx_error)?; 978 + Ok(row.map(|r| { 979 + let status = r 980 + .status 981 + .as_deref() 982 + .and_then(AccountStatus::parse) 983 + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); 984 + SequencedEvent { 985 + seq: r.seq.into(), 986 + did: Did::from(r.did), 987 + created_at: r.created_at, 988 + event_type: r.event_type, 989 + commit_cid: r.commit_cid.map(CidLink::from), 990 + prev_cid: r.prev_cid.map(CidLink::from), 991 + prev_data_cid: r.prev_data_cid.map(CidLink::from), 992 + ops: r.ops, 993 + blobs: r.blobs, 994 + blocks_cids: r.blocks_cids, 995 + handle: r.handle.map(Handle::from), 996 + active: r.active, 997 + status, 998 + rev: r.rev, 999 + } 1000 })) 1001 } 1002 1003 async fn get_events_since_cursor( 1004 &self, 1005 + cursor: SequenceNumber, 1006 limit: i64, 1007 ) -> Result<Vec<SequencedEvent>, DbError> { 1008 let rows = sqlx::query!( 1009 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 1010 ops, blobs, blocks_cids, handle, active, status, rev 1011 FROM repo_seq 1012 WHERE seq > $1 1013 ORDER BY seq ASC 1014 LIMIT $2"#, 1015 + cursor.as_i64(), 1016 limit 1017 ) 1018 .fetch_all(&self.pool) ··· 1020 .map_err(map_sqlx_error)?; 1021 Ok(rows 1022 .into_iter() 1023 + .map(|r| { 1024 + let status = r 1025 + .status 1026 + .as_deref() 1027 + .and_then(AccountStatus::parse) 1028 + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); 1029 + SequencedEvent { 1030 + seq: r.seq.into(), 1031 + did: Did::from(r.did), 1032 + created_at: r.created_at, 1033 + event_type: r.event_type, 1034 + commit_cid: r.commit_cid.map(CidLink::from), 1035 + prev_cid: r.prev_cid.map(CidLink::from), 1036 + prev_data_cid: r.prev_data_cid.map(CidLink::from), 1037 + ops: r.ops, 1038 + blobs: r.blobs, 1039 + blocks_cids: r.blocks_cids, 1040 + handle: r.handle.map(Handle::from), 1041 + active: r.active, 1042 + status, 1043 + rev: r.rev, 1044 + } 1045 }) 1046 .collect()) 1047 } ··· 1118 Ok(cid.map(CidLink::from)) 1119 } 1120 1121 + async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError> { 1122 + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq.as_i64())) 1123 .execute(&self.pool) 1124 .await 1125 .map_err(map_sqlx_error)?; ··· 1368 "#, 1369 ) 1370 .bind(&event.did) 1371 + .bind(event.event_type.as_str()) 1372 .bind(&event.commit_cid) 1373 .bind(&event.prev_cid) 1374 .bind(&event.ops) ··· 1414 Ok(rows 1415 .into_iter() 1416 .map(|r| BrokenGenesisCommit { 1417 + seq: r.seq.into(), 1418 did: Did::from(r.did), 1419 commit_cid: r.commit_cid.map(CidLink::from), 1420 })
+28 -28
crates/tranquil-db/src/postgres/session.rs
··· 2 use chrono::{DateTime, Utc}; 3 use sqlx::PgPool; 4 use tranquil_db_traits::{ 5 - AppPasswordCreate, AppPasswordRecord, DbError, RefreshSessionResult, SessionForRefresh, 6 - SessionListItem, SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, 7 - SessionTokenCreate, 8 }; 9 use tranquil_types::Did; 10 use uuid::Uuid; ··· 23 24 #[async_trait] 25 impl SessionRepository for PostgresSessionRepository { 26 - async fn create_session(&self, data: &SessionTokenCreate) -> Result<i32, DbError> { 27 let row = sqlx::query!( 28 r#" 29 INSERT INTO session_tokens ··· 37 data.refresh_jti, 38 data.access_expires_at, 39 data.refresh_expires_at, 40 - data.legacy_login, 41 data.mfa_verified, 42 data.scope, 43 data.controller_did.as_ref().map(|d| d.as_str()), ··· 47 .await 48 .map_err(map_sqlx_error)?; 49 50 - Ok(row.id) 51 } 52 53 async fn get_session_by_access_jti( ··· 69 .map_err(map_sqlx_error)?; 70 71 Ok(row.map(|r| SessionToken { 72 - id: r.id, 73 did: Did::from(r.did), 74 access_jti: r.access_jti, 75 refresh_jti: r.refresh_jti, 76 access_expires_at: r.access_expires_at, 77 refresh_expires_at: r.refresh_expires_at, 78 - legacy_login: r.legacy_login, 79 mfa_verified: r.mfa_verified, 80 scope: r.scope, 81 controller_did: r.controller_did.map(Did::from), ··· 104 .map_err(map_sqlx_error)?; 105 106 Ok(row.map(|r| SessionForRefresh { 107 - id: r.id, 108 did: Did::from(r.did), 109 scope: r.scope, 110 controller_did: r.controller_did.map(Did::from), ··· 115 116 async fn update_session_tokens( 117 &self, 118 - session_id: i32, 119 new_access_jti: &str, 120 new_refresh_jti: &str, 121 new_access_expires_at: DateTime<Utc>, ··· 132 new_refresh_jti, 133 new_access_expires_at, 134 new_refresh_expires_at, 135 - session_id 136 ) 137 .execute(&self.pool) 138 .await ··· 153 Ok(result.rows_affected()) 154 } 155 156 - async fn delete_session_by_id(&self, session_id: i32) -> Result<u64, DbError> { 157 - let result = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) 158 .execute(&self.pool) 159 .await 160 .map_err(map_sqlx_error)?; ··· 205 Ok(rows 206 .into_iter() 207 .map(|r| SessionListItem { 208 - id: r.id, 209 access_jti: r.access_jti, 210 created_at: r.created_at, 211 refresh_expires_at: r.refresh_expires_at, ··· 215 216 async fn get_session_access_jti_by_id( 217 &self, 218 - session_id: i32, 219 did: &Did, 220 ) -> Result<Option<String>, DbError> { 221 let row = sqlx::query_scalar!( 222 "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", 223 - session_id, 224 did.as_str() 225 ) 226 .fetch_optional(&self.pool) ··· 264 Ok(rows) 265 } 266 267 - async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<i32>, DbError> { 268 let row = sqlx::query_scalar!( 269 "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", 270 refresh_jti ··· 273 .await 274 .map_err(map_sqlx_error)?; 275 276 - Ok(row) 277 } 278 279 async fn mark_refresh_token_used( 280 &self, 281 refresh_jti: &str, 282 - session_id: i32, 283 ) -> Result<bool, DbError> { 284 let result = sqlx::query!( 285 r#" ··· 288 ON CONFLICT (refresh_jti) DO NOTHING 289 "#, 290 refresh_jti, 291 - session_id 292 ) 293 .execute(&self.pool) 294 .await ··· 319 name: r.name, 320 password_hash: r.password_hash, 321 created_at: r.created_at, 322 - privileged: r.privileged, 323 scopes: r.scopes, 324 created_by_controller_did: r.created_by_controller_did.map(Did::from), 325 }) ··· 352 name: r.name, 353 password_hash: r.password_hash, 354 created_at: r.created_at, 355 - privileged: r.privileged, 356 scopes: r.scopes, 357 created_by_controller_did: r.created_by_controller_did.map(Did::from), 358 }) ··· 383 name: r.name, 384 password_hash: r.password_hash, 385 created_at: r.created_at, 386 - privileged: r.privileged, 387 scopes: r.scopes, 388 created_by_controller_did: r.created_by_controller_did.map(Did::from), 389 })) ··· 399 data.user_id, 400 data.name, 401 data.password_hash, 402 - data.privileged, 403 data.scopes, 404 data.created_by_controller_did.as_ref().map(|d| d.as_str()) 405 ) ··· 480 .map_err(map_sqlx_error)?; 481 482 Ok(row.map(|r| SessionMfaStatus { 483 - legacy_login: r.legacy_login, 484 mfa_verified: r.mfa_verified, 485 last_reauth_at: r.last_reauth_at, 486 })) ··· 535 let result = sqlx::query!( 536 "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", 537 data.old_refresh_jti, 538 - data.session_id 539 ) 540 .execute(&mut *tx) 541 .await 542 .map_err(map_sqlx_error)?; 543 544 if result.rows_affected() == 0 { 545 - let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", data.session_id) 546 .execute(&mut *tx) 547 .await; 548 tx.commit().await.map_err(map_sqlx_error)?; ··· 555 data.new_refresh_jti, 556 data.new_access_expires_at, 557 data.new_refresh_expires_at, 558 - data.session_id 559 ) 560 .execute(&mut *tx) 561 .await
··· 2 use chrono::{DateTime, Utc}; 3 use sqlx::PgPool; 4 use tranquil_db_traits::{ 5 + AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, DbError, LoginType, 6 + RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, 7 + SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, 8 }; 9 use tranquil_types::Did; 10 use uuid::Uuid; ··· 23 24 #[async_trait] 25 impl SessionRepository for PostgresSessionRepository { 26 + async fn create_session(&self, data: &SessionTokenCreate) -> Result<SessionId, DbError> { 27 let row = sqlx::query!( 28 r#" 29 INSERT INTO session_tokens ··· 37 data.refresh_jti, 38 data.access_expires_at, 39 data.refresh_expires_at, 40 + bool::from(data.login_type), 41 data.mfa_verified, 42 data.scope, 43 data.controller_did.as_ref().map(|d| d.as_str()), ··· 47 .await 48 .map_err(map_sqlx_error)?; 49 50 + Ok(SessionId::new(row.id)) 51 } 52 53 async fn get_session_by_access_jti( ··· 69 .map_err(map_sqlx_error)?; 70 71 Ok(row.map(|r| SessionToken { 72 + id: SessionId::new(r.id), 73 did: Did::from(r.did), 74 access_jti: r.access_jti, 75 refresh_jti: r.refresh_jti, 76 access_expires_at: r.access_expires_at, 77 refresh_expires_at: r.refresh_expires_at, 78 + login_type: LoginType::from(r.legacy_login), 79 mfa_verified: r.mfa_verified, 80 scope: r.scope, 81 controller_did: r.controller_did.map(Did::from), ··· 104 .map_err(map_sqlx_error)?; 105 106 Ok(row.map(|r| SessionForRefresh { 107 + id: SessionId::new(r.id), 108 did: Did::from(r.did), 109 scope: r.scope, 110 controller_did: r.controller_did.map(Did::from), ··· 115 116 async fn update_session_tokens( 117 &self, 118 + session_id: SessionId, 119 new_access_jti: &str, 120 new_refresh_jti: &str, 121 new_access_expires_at: DateTime<Utc>, ··· 132 new_refresh_jti, 133 new_access_expires_at, 134 new_refresh_expires_at, 135 + session_id.as_i32() 136 ) 137 .execute(&self.pool) 138 .await ··· 153 Ok(result.rows_affected()) 154 } 155 156 + async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError> { 157 + let result = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id.as_i32()) 158 .execute(&self.pool) 159 .await 160 .map_err(map_sqlx_error)?; ··· 205 Ok(rows 206 .into_iter() 207 .map(|r| SessionListItem { 208 + id: SessionId::new(r.id), 209 access_jti: r.access_jti, 210 created_at: r.created_at, 211 refresh_expires_at: r.refresh_expires_at, ··· 215 216 async fn get_session_access_jti_by_id( 217 &self, 218 + session_id: SessionId, 219 did: &Did, 220 ) -> Result<Option<String>, DbError> { 221 let row = sqlx::query_scalar!( 222 "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", 223 + session_id.as_i32(), 224 did.as_str() 225 ) 226 .fetch_optional(&self.pool) ··· 264 Ok(rows) 265 } 266 267 + async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<SessionId>, DbError> { 268 let row = sqlx::query_scalar!( 269 "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", 270 refresh_jti ··· 273 .await 274 .map_err(map_sqlx_error)?; 275 276 + Ok(row.map(SessionId::new)) 277 } 278 279 async fn mark_refresh_token_used( 280 &self, 281 refresh_jti: &str, 282 + session_id: SessionId, 283 ) -> Result<bool, DbError> { 284 let result = sqlx::query!( 285 r#" ··· 288 ON CONFLICT (refresh_jti) DO NOTHING 289 "#, 290 refresh_jti, 291 + session_id.as_i32() 292 ) 293 .execute(&self.pool) 294 .await ··· 319 name: r.name, 320 password_hash: r.password_hash, 321 created_at: r.created_at, 322 + privilege: AppPasswordPrivilege::from(r.privileged), 323 scopes: r.scopes, 324 created_by_controller_did: r.created_by_controller_did.map(Did::from), 325 }) ··· 352 name: r.name, 353 password_hash: r.password_hash, 354 created_at: r.created_at, 355 + privilege: AppPasswordPrivilege::from(r.privileged), 356 scopes: r.scopes, 357 created_by_controller_did: r.created_by_controller_did.map(Did::from), 358 }) ··· 383 name: r.name, 384 password_hash: r.password_hash, 385 created_at: r.created_at, 386 + privilege: AppPasswordPrivilege::from(r.privileged), 387 scopes: r.scopes, 388 created_by_controller_did: r.created_by_controller_did.map(Did::from), 389 })) ··· 399 data.user_id, 400 data.name, 401 data.password_hash, 402 + bool::from(data.privilege), 403 data.scopes, 404 data.created_by_controller_did.as_ref().map(|d| d.as_str()) 405 ) ··· 480 .map_err(map_sqlx_error)?; 481 482 Ok(row.map(|r| SessionMfaStatus { 483 + login_type: LoginType::from(r.legacy_login), 484 mfa_verified: r.mfa_verified, 485 last_reauth_at: r.last_reauth_at, 486 })) ··· 535 let result = sqlx::query!( 536 "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", 537 data.old_refresh_jti, 538 + data.session_id.as_i32() 539 ) 540 .execute(&mut *tx) 541 .await 542 .map_err(map_sqlx_error)?; 543 544 if result.rows_affected() == 0 { 545 + let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", data.session_id.as_i32()) 546 .execute(&mut *tx) 547 .await; 548 tx.commit().await.map_err(map_sqlx_error)?; ··· 555 data.new_refresh_jti, 556 data.new_access_expires_at, 557 data.new_refresh_expires_at, 558 + data.session_id.as_i32() 559 ) 560 .execute(&mut *tx) 561 .await
+31 -26
crates/tranquil-db/src/postgres/sso.rs
··· 2 use chrono::Utc; 3 use sqlx::PgPool; 4 use tranquil_db_traits::{ 5 - DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, 6 }; 7 use tranquil_types::Did; 8 use uuid::Uuid; ··· 71 id: r.id, 72 did: Did::new_unchecked(&r.did), 73 provider: r.provider, 74 - provider_user_id: r.provider_user_id, 75 - provider_username: r.provider_username, 76 - provider_email: r.provider_email, 77 created_at: r.created_at, 78 updated_at: r.updated_at, 79 last_login_at: r.last_login_at, ··· 104 id: r.id, 105 did: Did::new_unchecked(&r.did), 106 provider: r.provider, 107 - provider_user_id: r.provider_user_id, 108 - provider_username: r.provider_username, 109 - provider_email: r.provider_email, 110 created_at: r.created_at, 111 updated_at: r.updated_at, 112 last_login_at: r.last_login_at, ··· 161 state: &str, 162 request_uri: &str, 163 provider: SsoProviderType, 164 - action: &str, 165 nonce: Option<&str>, 166 code_verifier: Option<&str>, 167 did: Option<&Did>, ··· 174 state, 175 request_uri, 176 provider as SsoProviderType, 177 - action, 178 nonce, 179 code_verifier, 180 did.map(|d| d.as_str()), ··· 200 .await 201 .map_err(map_sqlx_error)?; 202 203 - Ok(row.map(|r| SsoAuthState { 204 - state: r.state, 205 - request_uri: r.request_uri, 206 - provider: r.provider, 207 - action: r.action, 208 - nonce: r.nonce, 209 - code_verifier: r.code_verifier, 210 - did: r.did.map(|d| Did::new_unchecked(&d)), 211 - created_at: r.created_at, 212 - expires_at: r.expires_at, 213 - })) 214 } 215 216 async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError> { ··· 280 token: r.token, 281 request_uri: r.request_uri, 282 provider: r.provider, 283 - provider_user_id: r.provider_user_id, 284 - provider_username: r.provider_username, 285 - provider_email: r.provider_email, 286 provider_email_verified: r.provider_email_verified, 287 created_at: r.created_at, 288 expires_at: r.expires_at, ··· 311 token: r.token, 312 request_uri: r.request_uri, 313 provider: r.provider, 314 - provider_user_id: r.provider_user_id, 315 - provider_username: r.provider_username, 316 - provider_email: r.provider_email, 317 provider_email_verified: r.provider_email_verified, 318 created_at: r.created_at, 319 expires_at: r.expires_at,
··· 2 use chrono::Utc; 3 use sqlx::PgPool; 4 use tranquil_db_traits::{ 5 + DbError, ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction, 6 + SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, 7 }; 8 use tranquil_types::Did; 9 use uuid::Uuid; ··· 72 id: r.id, 73 did: Did::new_unchecked(&r.did), 74 provider: r.provider, 75 + provider_user_id: ExternalUserId::from(r.provider_user_id), 76 + provider_username: r.provider_username.map(ExternalUsername::from), 77 + provider_email: r.provider_email.map(ExternalEmail::from), 78 created_at: r.created_at, 79 updated_at: r.updated_at, 80 last_login_at: r.last_login_at, ··· 105 id: r.id, 106 did: Did::new_unchecked(&r.did), 107 provider: r.provider, 108 + provider_user_id: ExternalUserId::from(r.provider_user_id), 109 + provider_username: r.provider_username.map(ExternalUsername::from), 110 + provider_email: r.provider_email.map(ExternalEmail::from), 111 created_at: r.created_at, 112 updated_at: r.updated_at, 113 last_login_at: r.last_login_at, ··· 162 state: &str, 163 request_uri: &str, 164 provider: SsoProviderType, 165 + action: SsoAction, 166 nonce: Option<&str>, 167 code_verifier: Option<&str>, 168 did: Option<&Did>, ··· 175 state, 176 request_uri, 177 provider as SsoProviderType, 178 + action.as_str(), 179 nonce, 180 code_verifier, 181 did.map(|d| d.as_str()), ··· 201 .await 202 .map_err(map_sqlx_error)?; 203 204 + row.map(|r| { 205 + let action = SsoAction::parse(&r.action).ok_or(DbError::NotFound)?; 206 + Ok(SsoAuthState { 207 + state: r.state, 208 + request_uri: r.request_uri, 209 + provider: r.provider, 210 + action, 211 + nonce: r.nonce, 212 + code_verifier: r.code_verifier, 213 + did: r.did.map(|d| Did::new_unchecked(&d)), 214 + created_at: r.created_at, 215 + expires_at: r.expires_at, 216 + }) 217 + }) 218 + .transpose() 219 } 220 221 async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError> { ··· 285 token: r.token, 286 request_uri: r.request_uri, 287 provider: r.provider, 288 + provider_user_id: ExternalUserId::from(r.provider_user_id), 289 + provider_username: r.provider_username.map(ExternalUsername::from), 290 + provider_email: r.provider_email.map(ExternalEmail::from), 291 provider_email_verified: r.provider_email_verified, 292 created_at: r.created_at, 293 expires_at: r.expires_at, ··· 316 token: r.token, 317 request_uri: r.request_uri, 318 provider: r.provider, 319 + provider_user_id: ExternalUserId::from(r.provider_user_id), 320 + provider_username: r.provider_username.map(ExternalUsername::from), 321 + provider_email: r.provider_email.map(ExternalEmail::from), 322 provider_email_verified: r.provider_email_verified, 323 created_at: r.created_at, 324 expires_at: r.expires_at,
+65 -45
crates/tranquil-db/src/postgres/user.rs
··· 5 use uuid::Uuid; 6 7 use tranquil_db_traits::{ 8 - AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs, 9 - OAuthTokenWithUser, PasswordResetResult, SsoProviderType, StoredBackupCode, StoredPasskey, 10 - TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, 11 - UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, 12 - UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, 13 - UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, 14 - UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, 15 - UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, 16 - UserStatus, UserVerificationInfo, UserWithKey, 17 }; 18 19 pub struct PostgresUserRepository { ··· 280 password_hash: r.password_hash, 281 deactivated_at: r.deactivated_at, 282 takedown_ref: r.takedown_ref, 283 - email_verified: r.email_verified, 284 - discord_verified: r.discord_verified, 285 - telegram_verified: r.telegram_verified, 286 - signal_verified: r.signal_verified, 287 })) 288 } 289 ··· 308 309 async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> { 310 let row = sqlx::query!( 311 - r#"SELECT email, handle, preferred_comms_channel::text as "preferred_channel!", preferred_locale 312 FROM users WHERE id = $1"#, 313 user_id 314 ) ··· 601 let row = sqlx::query!( 602 r#"SELECT 603 email, 604 - preferred_comms_channel::text as "preferred_channel!", 605 discord_id, 606 discord_verified, 607 telegram_username, ··· 647 async fn update_preferred_comms_channel( 648 &self, 649 did: &Did, 650 - channel: &str, 651 ) -> Result<(), DbError> { 652 - sqlx::query( 653 - "UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2", 654 ) 655 - .bind(channel) 656 - .bind(did.as_str()) 657 .execute(&self.pool) 658 .await 659 .map_err(map_sqlx_error)?; ··· 709 id: r.id, 710 handle: Handle::from(r.handle), 711 email: r.email, 712 - email_verified: r.email_verified, 713 - discord_verified: r.discord_verified, 714 - telegram_verified: r.telegram_verified, 715 - signal_verified: r.signal_verified, 716 })) 717 } 718 ··· 1065 })) 1066 } 1067 1068 async fn upsert_totp_secret( 1069 &self, 1070 did: &Did, ··· 1300 preferred_comms_channel as "preferred_comms_channel!: CommsChannel", 1301 deactivated_at, takedown_ref, 1302 email_verified, discord_verified, telegram_verified, signal_verified, 1303 - account_type::text as "account_type!" 1304 FROM users 1305 WHERE handle = $1 OR email = $1 1306 "#, ··· 1320 preferred_comms_channel: row.preferred_comms_channel, 1321 deactivated_at: row.deactivated_at, 1322 takedown_ref: row.takedown_ref, 1323 - email_verified: row.email_verified, 1324 - discord_verified: row.discord_verified, 1325 - telegram_verified: row.telegram_verified, 1326 - signal_verified: row.signal_verified, 1327 account_type: row.account_type, 1328 }) 1329 }) ··· 1348 id: row.id, 1349 two_factor_enabled: row.two_factor_enabled, 1350 preferred_comms_channel: row.preferred_comms_channel, 1351 - email_verified: row.email_verified, 1352 - discord_verified: row.discord_verified, 1353 - telegram_verified: row.telegram_verified, 1354 - signal_verified: row.signal_verified, 1355 }) 1356 }) 1357 } ··· 1376 opt.map(|row| UserSessionInfo { 1377 handle: Handle::from(row.handle), 1378 email: row.email, 1379 - email_verified: row.email_verified, 1380 is_admin: row.is_admin, 1381 deactivated_at: row.deactivated_at, 1382 takedown_ref: row.takedown_ref, 1383 preferred_locale: row.preferred_locale, 1384 preferred_comms_channel: row.preferred_comms_channel, 1385 - discord_verified: row.discord_verified, 1386 - telegram_verified: row.telegram_verified, 1387 - signal_verified: row.signal_verified, 1388 migrated_to_pds: row.migrated_to_pds, 1389 migrated_at: row.migrated_at, 1390 }) ··· 1469 email: row.email, 1470 deactivated_at: row.deactivated_at, 1471 takedown_ref: row.takedown_ref, 1472 - email_verified: row.email_verified, 1473 - discord_verified: row.discord_verified, 1474 - telegram_verified: row.telegram_verified, 1475 - signal_verified: row.signal_verified, 1476 allow_legacy_login: row.allow_legacy_login, 1477 migrated_to_pds: row.migrated_to_pds, 1478 preferred_comms_channel: row.preferred_comms_channel, ··· 1543 discord_id: row.discord_id, 1544 telegram_username: row.telegram_username, 1545 signal_number: row.signal_number, 1546 - email_verified: row.email_verified, 1547 - discord_verified: row.discord_verified, 1548 - telegram_verified: row.telegram_verified, 1549 - signal_verified: row.signal_verified, 1550 }) 1551 }) 1552 }
··· 5 use uuid::Uuid; 6 7 use tranquil_db_traits::{ 8 + AccountSearchResult, AccountType, ChannelVerificationStatus, CommsChannel, DbError, 9 + DidWebOverrides, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, SsoProviderType, 10 + StoredBackupCode, StoredPasskey, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo, 11 + UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, 12 + UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery, 13 + UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, 14 + UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, 15 + UserPasswordInfo, UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, 16 + UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey, 17 }; 18 19 pub struct PostgresUserRepository { ··· 280 password_hash: r.password_hash, 281 deactivated_at: r.deactivated_at, 282 takedown_ref: r.takedown_ref, 283 + channel_verification: ChannelVerificationStatus::new( 284 + r.email_verified, 285 + r.discord_verified, 286 + r.telegram_verified, 287 + r.signal_verified, 288 + ), 289 })) 290 } 291 ··· 310 311 async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> { 312 let row = sqlx::query!( 313 + r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale 314 FROM users WHERE id = $1"#, 315 user_id 316 ) ··· 603 let row = sqlx::query!( 604 r#"SELECT 605 email, 606 + preferred_comms_channel as "preferred_channel!: CommsChannel", 607 discord_id, 608 discord_verified, 609 telegram_username, ··· 649 async fn update_preferred_comms_channel( 650 &self, 651 did: &Did, 652 + channel: CommsChannel, 653 ) -> Result<(), DbError> { 654 + sqlx::query!( 655 + "UPDATE users SET preferred_comms_channel = $1, updated_at = NOW() WHERE did = $2", 656 + channel as CommsChannel, 657 + did.as_str() 658 ) 659 .execute(&self.pool) 660 .await 661 .map_err(map_sqlx_error)?; ··· 711 id: r.id, 712 handle: Handle::from(r.handle), 713 email: r.email, 714 + channel_verification: ChannelVerificationStatus::new( 715 + r.email_verified, 716 + r.discord_verified, 717 + r.telegram_verified, 718 + r.signal_verified, 719 + ), 720 })) 721 } 722 ··· 1069 })) 1070 } 1071 1072 + async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError> { 1073 + self.get_totp_record(did) 1074 + .await 1075 + .map(|opt| opt.map(TotpRecordState::from)) 1076 + } 1077 + 1078 async fn upsert_totp_secret( 1079 &self, 1080 did: &Did, ··· 1310 preferred_comms_channel as "preferred_comms_channel!: CommsChannel", 1311 deactivated_at, takedown_ref, 1312 email_verified, discord_verified, telegram_verified, signal_verified, 1313 + account_type as "account_type!: AccountType" 1314 FROM users 1315 WHERE handle = $1 OR email = $1 1316 "#, ··· 1330 preferred_comms_channel: row.preferred_comms_channel, 1331 deactivated_at: row.deactivated_at, 1332 takedown_ref: row.takedown_ref, 1333 + channel_verification: ChannelVerificationStatus::new( 1334 + row.email_verified, 1335 + row.discord_verified, 1336 + row.telegram_verified, 1337 + row.signal_verified, 1338 + ), 1339 account_type: row.account_type, 1340 }) 1341 }) ··· 1360 id: row.id, 1361 two_factor_enabled: row.two_factor_enabled, 1362 preferred_comms_channel: row.preferred_comms_channel, 1363 + channel_verification: ChannelVerificationStatus::new( 1364 + row.email_verified, 1365 + row.discord_verified, 1366 + row.telegram_verified, 1367 + row.signal_verified, 1368 + ), 1369 }) 1370 }) 1371 } ··· 1390 opt.map(|row| UserSessionInfo { 1391 handle: Handle::from(row.handle), 1392 email: row.email, 1393 is_admin: row.is_admin, 1394 deactivated_at: row.deactivated_at, 1395 takedown_ref: row.takedown_ref, 1396 preferred_locale: row.preferred_locale, 1397 preferred_comms_channel: row.preferred_comms_channel, 1398 + channel_verification: ChannelVerificationStatus::new( 1399 + row.email_verified, 1400 + row.discord_verified, 1401 + row.telegram_verified, 1402 + row.signal_verified, 1403 + ), 1404 migrated_to_pds: row.migrated_to_pds, 1405 migrated_at: row.migrated_at, 1406 }) ··· 1485 email: row.email, 1486 deactivated_at: row.deactivated_at, 1487 takedown_ref: row.takedown_ref, 1488 + channel_verification: ChannelVerificationStatus::new( 1489 + row.email_verified, 1490 + row.discord_verified, 1491 + row.telegram_verified, 1492 + row.signal_verified, 1493 + ), 1494 allow_legacy_login: row.allow_legacy_login, 1495 migrated_to_pds: row.migrated_to_pds, 1496 preferred_comms_channel: row.preferred_comms_channel, ··· 1561 discord_id: row.discord_id, 1562 telegram_username: row.telegram_username, 1563 signal_number: row.signal_number, 1564 + channel_verification: ChannelVerificationStatus::new( 1565 + row.email_verified, 1566 + row.discord_verified, 1567 + row.telegram_verified, 1568 + row.signal_verified, 1569 + ), 1570 }) 1571 }) 1572 }
+8 -18
crates/tranquil-pds/src/api/admin/status.rs
··· 207 } 208 if let Some(takedown) = &input.takedown { 209 let status = if takedown.applied { 210 - Some("takendown") 211 } else { 212 - None 213 }; 214 - if let Err(e) = crate::api::repo::record::sequence_account_event( 215 - &state, 216 - &did, 217 - !takedown.applied, 218 - status, 219 - ) 220 - .await 221 { 222 warn!("Failed to sequence account event for takedown: {}", e); 223 } 224 } 225 if let Some(deactivated) = &input.deactivated { 226 let status = if deactivated.applied { 227 - Some("deactivated") 228 } else { 229 - None 230 }; 231 - if let Err(e) = crate::api::repo::record::sequence_account_event( 232 - &state, 233 - &did, 234 - !deactivated.applied, 235 - status, 236 - ) 237 - .await 238 { 239 warn!("Failed to sequence account event for deactivation: {}", e); 240 }
··· 207 } 208 if let Some(takedown) = &input.takedown { 209 let status = if takedown.applied { 210 + tranquil_db_traits::AccountStatus::Takendown 211 } else { 212 + tranquil_db_traits::AccountStatus::Active 213 }; 214 + if let Err(e) = 215 + crate::api::repo::record::sequence_account_event(&state, &did, status).await 216 { 217 warn!("Failed to sequence account event for takedown: {}", e); 218 } 219 } 220 if let Some(deactivated) = &input.deactivated { 221 let status = if deactivated.applied { 222 + tranquil_db_traits::AccountStatus::Deactivated 223 } else { 224 + tranquil_db_traits::AccountStatus::Active 225 }; 226 + if let Err(e) = 227 + crate::api::repo::record::sequence_account_event(&state, &did, status).await 228 { 229 warn!("Failed to sequence account event for deactivation: {}", e); 230 }
+5
crates/tranquil-pds/src/api/repo/record/mod.rs
··· 1 pub mod batch; 2 pub mod delete; 3 pub mod read; 4 pub mod utils; 5 pub mod validation; 6 pub mod write; 7 8 pub use batch::apply_writes; 9 pub use delete::{DeleteRecordInput, delete_record, delete_record_internal}; 10 pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
··· 1 pub mod batch; 2 pub mod delete; 3 + pub mod pagination; 4 pub mod read; 5 pub mod utils; 6 pub mod validation; 7 + pub mod validation_mode; 8 pub mod write; 9 10 + pub use pagination::PaginationDirection; 11 + pub use validation_mode::ValidationMode; 12 + 13 pub use batch::apply_writes; 14 pub use delete::{DeleteRecordInput, delete_record, delete_record_internal}; 15 pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
+31
crates/tranquil-pds/src/api/repo/record/pagination.rs
···
··· 1 + use serde::{Deserialize, Deserializer}; 2 + 3 + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] 4 + pub enum PaginationDirection { 5 + #[default] 6 + Forward, 7 + Backward, 8 + } 9 + 10 + impl PaginationDirection { 11 + pub fn from_optional_bool(value: Option<bool>) -> Self { 12 + match value { 13 + Some(true) => Self::Backward, 14 + Some(false) | None => Self::Forward, 15 + } 16 + } 17 + 18 + pub fn is_reverse(&self) -> bool { 19 + matches!(self, Self::Backward) 20 + } 21 + } 22 + 23 + pub fn deserialize_pagination_direction<'de, D>( 24 + deserializer: D, 25 + ) -> Result<PaginationDirection, D::Error> 26 + where 27 + D: Deserializer<'de>, 28 + { 29 + let opt: Option<bool> = Option::deserialize(deserializer)?; 30 + Ok(PaginationDirection::from_optional_bool(opt)) 31 + }
+9 -8
crates/tranquil-pds/src/api/repo/record/utils.rs
··· 8 use k256::ecdsa::SigningKey; 9 use serde_json::{Value, json}; 10 use std::str::FromStr; 11 use uuid::Uuid; 12 13 pub fn extract_blob_cids(record: &Value) -> Vec<String> { ··· 139 ) -> Result<CommitResult, String> { 140 use tranquil_db_traits::{ 141 ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert, 142 }; 143 144 let CommitParams { ··· 263 264 let commit_event = CommitEventData { 265 did: did.clone(), 266 - event_type: "commit".to_string(), 267 commit_cid: Some(crate::types::CidLink::new_unchecked( 268 new_root_cid.to_string(), 269 )), ··· 417 state: &AppState, 418 did: &Did, 419 handle: Option<&Handle>, 420 - ) -> Result<i64, String> { 421 state 422 .repo_repo 423 .insert_identity_event(did, handle) ··· 427 pub async fn sequence_account_event( 428 state: &AppState, 429 did: &Did, 430 - active: bool, 431 - status: Option<&str>, 432 - ) -> Result<i64, String> { 433 state 434 .repo_repo 435 - .insert_account_event(did, active, status) 436 .await 437 .map_err(|e| format!("DB Error (account event): {}", e)) 438 } ··· 441 did: &Did, 442 commit_cid: &str, 443 rev: Option<&str>, 444 - ) -> Result<i64, String> { 445 let cid_link = crate::types::CidLink::new_unchecked(commit_cid); 446 state 447 .repo_repo ··· 456 commit_cid: &Cid, 457 mst_root_cid: &Cid, 458 rev: &str, 459 - ) -> Result<i64, String> { 460 let commit_cid_link = crate::types::CidLink::new_unchecked(commit_cid.to_string()); 461 let mst_root_cid_link = crate::types::CidLink::new_unchecked(mst_root_cid.to_string()); 462 state
··· 8 use k256::ecdsa::SigningKey; 9 use serde_json::{Value, json}; 10 use std::str::FromStr; 11 + use tranquil_db_traits::SequenceNumber; 12 use uuid::Uuid; 13 14 pub fn extract_blob_cids(record: &Value) -> Vec<String> { ··· 140 ) -> Result<CommitResult, String> { 141 use tranquil_db_traits::{ 142 ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert, 143 + RepoEventType, 144 }; 145 146 let CommitParams { ··· 265 266 let commit_event = CommitEventData { 267 did: did.clone(), 268 + event_type: RepoEventType::Commit, 269 commit_cid: Some(crate::types::CidLink::new_unchecked( 270 new_root_cid.to_string(), 271 )), ··· 419 state: &AppState, 420 did: &Did, 421 handle: Option<&Handle>, 422 + ) -> Result<SequenceNumber, String> { 423 state 424 .repo_repo 425 .insert_identity_event(did, handle) ··· 429 pub async fn sequence_account_event( 430 state: &AppState, 431 did: &Did, 432 + status: tranquil_db_traits::AccountStatus, 433 + ) -> Result<SequenceNumber, String> { 434 state 435 .repo_repo 436 + .insert_account_event(did, status) 437 .await 438 .map_err(|e| format!("DB Error (account event): {}", e)) 439 } ··· 442 did: &Did, 443 commit_cid: &str, 444 rev: Option<&str>, 445 + ) -> Result<SequenceNumber, String> { 446 let cid_link = crate::types::CidLink::new_unchecked(commit_cid); 447 state 448 .repo_repo ··· 457 commit_cid: &Cid, 458 mst_root_cid: &Cid, 459 rev: &str, 460 + ) -> Result<SequenceNumber, String> { 461 let commit_cid_link = crate::types::CidLink::new_unchecked(commit_cid.to_string()); 462 let mst_root_cid_link = crate::types::CidLink::new_unchecked(mst_root_cid.to_string()); 463 state
+35
crates/tranquil-pds/src/api/repo/record/validation_mode.rs
···
··· 1 + use serde::{Deserialize, Deserializer}; 2 + 3 + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] 4 + pub enum ValidationMode { 5 + Skip, 6 + #[default] 7 + Infer, 8 + Strict, 9 + } 10 + 11 + impl ValidationMode { 12 + pub fn from_optional_bool(value: Option<bool>) -> Self { 13 + match value { 14 + Some(false) => Self::Skip, 15 + Some(true) => Self::Strict, 16 + None => Self::Infer, 17 + } 18 + } 19 + 20 + pub fn should_skip(&self) -> bool { 21 + matches!(self, Self::Skip) 22 + } 23 + 24 + pub fn requires_lexicon(&self) -> bool { 25 + matches!(self, Self::Strict) 26 + } 27 + } 28 + 29 + pub fn deserialize_validation_mode<'de, D>(deserializer: D) -> Result<ValidationMode, D::Error> 30 + where 31 + D: Deserializer<'de>, 32 + { 33 + let opt: Option<bool> = Option::deserialize(deserializer)?; 34 + Ok(ValidationMode::from_optional_bool(opt)) 35 + }
+61
crates/tranquil-pds/src/auth/account_verified.rs
···
··· 1 + use axum::response::{IntoResponse, Response}; 2 + 3 + use super::AuthenticatedUser; 4 + use crate::api::error::ApiError; 5 + use crate::state::AppState; 6 + use crate::types::Did; 7 + 8 + pub struct AccountVerified<'a> { 9 + user: &'a AuthenticatedUser, 10 + } 11 + 12 + impl<'a> AccountVerified<'a> { 13 + pub fn did(&self) -> &Did { 14 + &self.user.did 15 + } 16 + 17 + pub fn user(&self) -> &AuthenticatedUser { 18 + self.user 19 + } 20 + } 21 + 22 + pub async fn require_verified_or_delegated<'a>( 23 + state: &AppState, 24 + user: &'a AuthenticatedUser, 25 + ) -> Result<AccountVerified<'a>, Response> { 26 + let is_verified = state 27 + .user_repo 28 + .has_verified_comms_channel(&user.did) 29 + .await 30 + .unwrap_or(false); 31 + 32 + if is_verified { 33 + return Ok(AccountVerified { user }); 34 + } 35 + 36 + let is_delegated = state 37 + .delegation_repo 38 + .is_delegated_account(&user.did) 39 + .await 40 + .unwrap_or(false); 41 + 42 + if is_delegated { 43 + return Ok(AccountVerified { user }); 44 + } 45 + 46 + Err(ApiError::AccountNotVerified.into_response()) 47 + } 48 + 49 + pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> { 50 + match state.user_repo.is_account_migrated(did).await { 51 + Ok(true) => Err(ApiError::AccountMigrated.into_response()), 52 + Ok(false) => Ok(()), 53 + Err(e) => { 54 + tracing::error!("Failed to check migration status: {:?}", e); 55 + Err( 56 + ApiError::InternalError(Some("Failed to verify migration status".into())) 57 + .into_response(), 58 + ) 59 + } 60 + } 61 + }
-547
crates/tranquil-pds/src/auth/auth_extractor.rs
··· 1 - mod common; 2 - mod helpers; 3 - 4 - use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5 - use chrono::Utc; 6 - use common::{base_url, client, create_account_and_login, pds_endpoint}; 7 - use helpers::verify_new_account; 8 - use reqwest::StatusCode; 9 - use serde_json::{Value, json}; 10 - use sha2::{Digest, Sha256}; 11 - use wiremock::matchers::{method, path}; 12 - use wiremock::{Mock, MockServer, ResponseTemplate}; 13 - 14 - fn generate_pkce() -> (String, String) { 15 - let verifier_bytes: [u8; 32] = rand::random(); 16 - let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 17 - let mut hasher = Sha256::new(); 18 - hasher.update(code_verifier.as_bytes()); 19 - let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); 20 - (code_verifier, code_challenge) 21 - } 22 - 23 - async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer { 24 - let mock_server = MockServer::start().await; 25 - let metadata = json!({ 26 - "client_id": mock_server.uri(), 27 - "client_name": "Auth Extractor Test Client", 28 - "redirect_uris": [redirect_uri], 29 - "grant_types": ["authorization_code", "refresh_token"], 30 - "response_types": ["code"], 31 - "token_endpoint_auth_method": "none", 32 - "dpop_bound_access_tokens": dpop_bound 33 - }); 34 - Mock::given(method("GET")) 35 - .and(path("/")) 36 - .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 37 - .mount(&mock_server) 38 - .await; 39 - mock_server 40 - } 41 - 42 - async fn get_oauth_session( 43 - http_client: &reqwest::Client, 44 - url: &str, 45 - dpop_bound: bool, 46 - ) -> (String, String, String, String) { 47 - let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 48 - let handle = format!("ae{}", suffix); 49 - let password = "AuthExtract123!"; 50 - let create_res = http_client 51 - .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 52 - .json(&json!({ 53 - "handle": handle, 54 - "email": format!("{}@example.com", handle), 55 - "password": password 56 - })) 57 - .send() 58 - .await 59 - .unwrap(); 60 - assert_eq!(create_res.status(), StatusCode::OK); 61 - let account: Value = create_res.json().await.unwrap(); 62 - let did = account["did"].as_str().unwrap().to_string(); 63 - verify_new_account(http_client, &did).await; 64 - 65 - let redirect_uri = "https://example.com/auth-callback"; 66 - let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await; 67 - let client_id = mock_client.uri(); 68 - let (code_verifier, code_challenge) = generate_pkce(); 69 - 70 - let par_body: Value = http_client 71 - .post(format!("{}/oauth/par", url)) 72 - .form(&[ 73 - ("response_type", "code"), 74 - ("client_id", &client_id), 75 - ("redirect_uri", redirect_uri), 76 - ("code_challenge", &code_challenge), 77 - ("code_challenge_method", "S256"), 78 - ]) 79 - .send() 80 - .await 81 - .unwrap() 82 - .json() 83 - .await 84 - .unwrap(); 85 - let request_uri = par_body["request_uri"].as_str().unwrap(); 86 - 87 - let auth_res = http_client 88 - .post(format!("{}/oauth/authorize", url)) 89 - .header("Content-Type", "application/json") 90 - .header("Accept", "application/json") 91 - .json(&json!({ 92 - "request_uri": request_uri, 93 - "username": &handle, 94 - "password": password, 95 - "remember_device": false 96 - })) 97 - .send() 98 - .await 99 - .unwrap(); 100 - let auth_body: Value = auth_res.json().await.unwrap(); 101 - let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 102 - 103 - if location.contains("/oauth/consent") { 104 - let consent_res = http_client 105 - .post(format!("{}/oauth/authorize/consent", url)) 106 - .header("Content-Type", "application/json") 107 - .json(&json!({ 108 - "request_uri": request_uri, 109 - "approved_scopes": ["atproto"], 110 - "remember": false 111 - })) 112 - .send() 113 - .await 114 - .unwrap(); 115 - let consent_body: Value = consent_res.json().await.unwrap(); 116 - location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 117 - } 118 - 119 - let code = location 120 - .split("code=") 121 - .nth(1) 122 - .unwrap() 123 - .split('&') 124 - .next() 125 - .unwrap(); 126 - 127 - let token_body: Value = http_client 128 - .post(format!("{}/oauth/token", url)) 129 - .form(&[ 130 - ("grant_type", "authorization_code"), 131 - ("code", code), 132 - ("redirect_uri", redirect_uri), 133 - ("code_verifier", &code_verifier), 134 - ("client_id", &client_id), 135 - ]) 136 - .send() 137 - .await 138 - .unwrap() 139 - .json() 140 - .await 141 - .unwrap(); 142 - 143 - ( 144 - token_body["access_token"].as_str().unwrap().to_string(), 145 - token_body["refresh_token"].as_str().unwrap().to_string(), 146 - client_id, 147 - did, 148 - ) 149 - } 150 - 151 - #[tokio::test] 152 - async fn test_oauth_token_works_with_bearer_auth() { 153 - let url = base_url().await; 154 - let http_client = client(); 155 - let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await; 156 - 157 - let res = http_client 158 - .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 159 - .bearer_auth(&access_token) 160 - .send() 161 - .await 162 - .unwrap(); 163 - 164 - assert_eq!(res.status(), StatusCode::OK, "OAuth token should work with RequiredAuth extractor"); 165 - let body: Value = res.json().await.unwrap(); 166 - assert_eq!(body["did"].as_str().unwrap(), did); 167 - } 168 - 169 - #[tokio::test] 170 - async fn test_session_token_still_works() { 171 - let url = base_url().await; 172 - let http_client = client(); 173 - let (jwt, did) = create_account_and_login(&http_client).await; 174 - 175 - let res = http_client 176 - .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 177 - .bearer_auth(&jwt) 178 - .send() 179 - .await 180 - .unwrap(); 181 - 182 - assert_eq!(res.status(), StatusCode::OK, "Session token should still work"); 183 - let body: Value = res.json().await.unwrap(); 184 - assert_eq!(body["did"].as_str().unwrap(), did); 185 - } 186 - 187 - 188 - #[tokio::test] 189 - async fn test_oauth_admin_extractor_allows_oauth_tokens() { 190 - let url = base_url().await; 191 - let http_client = client(); 192 - 193 - let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 194 - let handle = format!("adm{}", suffix); 195 - let password = "AdminOAuth123!"; 196 - let create_res = http_client 197 - .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 198 - .json(&json!({ 199 - "handle": handle, 200 - "email": format!("{}@example.com", handle), 201 - "password": password 202 - })) 203 - .send() 204 - .await 205 - .unwrap(); 206 - assert_eq!(create_res.status(), StatusCode::OK); 207 - let account: Value = create_res.json().await.unwrap(); 208 - let did = account["did"].as_str().unwrap().to_string(); 209 - verify_new_account(&http_client, &did).await; 210 - 211 - let pool = common::get_test_db_pool().await; 212 - sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did) 213 - .execute(pool) 214 - .await 215 - .expect("Failed to mark user as admin"); 216 - 217 - let redirect_uri = "https://example.com/admin-callback"; 218 - let mock_client = setup_mock_client_metadata(redirect_uri, false).await; 219 - let client_id = mock_client.uri(); 220 - let (code_verifier, code_challenge) = generate_pkce(); 221 - 222 - let par_body: Value = http_client 223 - .post(format!("{}/oauth/par", url)) 224 - .form(&[ 225 - ("response_type", "code"), 226 - ("client_id", &client_id), 227 - ("redirect_uri", redirect_uri), 228 - ("code_challenge", &code_challenge), 229 - ("code_challenge_method", "S256"), 230 - ]) 231 - .send() 232 - .await 233 - .unwrap() 234 - .json() 235 - .await 236 - .unwrap(); 237 - let request_uri = par_body["request_uri"].as_str().unwrap(); 238 - 239 - let auth_res = http_client 240 - .post(format!("{}/oauth/authorize", url)) 241 - .header("Content-Type", "application/json") 242 - .header("Accept", "application/json") 243 - .json(&json!({ 244 - "request_uri": request_uri, 245 - "username": &handle, 246 - "password": password, 247 - "remember_device": false 248 - })) 249 - .send() 250 - .await 251 - .unwrap(); 252 - let auth_body: Value = auth_res.json().await.unwrap(); 253 - let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 254 - if location.contains("/oauth/consent") { 255 - let consent_res = http_client 256 - .post(format!("{}/oauth/authorize/consent", url)) 257 - .header("Content-Type", "application/json") 258 - .json(&json!({ 259 - "request_uri": request_uri, 260 - "approved_scopes": ["atproto"], 261 - "remember": false 262 - })) 263 - .send() 264 - .await 265 - .unwrap(); 266 - let consent_body: Value = consent_res.json().await.unwrap(); 267 - location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 268 - } 269 - 270 - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 271 - let token_body: Value = http_client 272 - .post(format!("{}/oauth/token", url)) 273 - .form(&[ 274 - ("grant_type", "authorization_code"), 275 - ("code", code), 276 - ("redirect_uri", redirect_uri), 277 - ("code_verifier", &code_verifier), 278 - ("client_id", &client_id), 279 - ]) 280 - .send() 281 - .await 282 - .unwrap() 283 - .json() 284 - .await 285 - .unwrap(); 286 - let access_token = token_body["access_token"].as_str().unwrap(); 287 - 288 - let res = http_client 289 - .get(format!("{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", url, did)) 290 - .bearer_auth(access_token) 291 - .send() 292 - .await 293 - .unwrap(); 294 - 295 - assert_eq!( 296 - res.status(), 297 - StatusCode::OK, 298 - "OAuth token for admin user should work with admin endpoint" 299 - ); 300 - } 301 - 302 - #[tokio::test] 303 - async fn test_expired_oauth_token_returns_proper_error() { 304 - let url = base_url().await; 305 - let http_client = client(); 306 - 307 - let now = Utc::now().timestamp(); 308 - let header = json!({"alg": "HS256", "typ": "at+jwt"}); 309 - let payload = json!({ 310 - "iss": url, 311 - "sub": "did:plc:test123", 312 - "aud": url, 313 - "iat": now - 7200, 314 - "exp": now - 3600, 315 - "jti": "expired-token", 316 - "sid": "expired-session", 317 - "scope": "atproto", 318 - "client_id": "https://example.com" 319 - }); 320 - let fake_token = format!( 321 - "{}.{}.{}", 322 - URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), 323 - URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), 324 - URL_SAFE_NO_PAD.encode([1u8; 32]) 325 - ); 326 - 327 - let res = http_client 328 - .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 329 - .bearer_auth(&fake_token) 330 - .send() 331 - .await 332 - .unwrap(); 333 - 334 - assert_eq!( 335 - res.status(), 336 - StatusCode::UNAUTHORIZED, 337 - "Expired token should be rejected" 338 - ); 339 - } 340 - 341 - #[tokio::test] 342 - async fn test_dpop_nonce_error_has_proper_headers() { 343 - let url = base_url().await; 344 - let pds_url = pds_endpoint(); 345 - let http_client = client(); 346 - 347 - let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 348 - let handle = format!("dpop{}", suffix); 349 - let create_res = http_client 350 - .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 351 - .json(&json!({ 352 - "handle": handle, 353 - "email": format!("{}@test.com", handle), 354 - "password": "DpopTest123!" 355 - })) 356 - .send() 357 - .await 358 - .unwrap(); 359 - assert_eq!(create_res.status(), StatusCode::OK); 360 - let account: Value = create_res.json().await.unwrap(); 361 - let did = account["did"].as_str().unwrap(); 362 - verify_new_account(&http_client, did).await; 363 - 364 - let redirect_uri = "https://example.com/dpop-callback"; 365 - let mock_server = MockServer::start().await; 366 - let client_id = mock_server.uri(); 367 - let metadata = json!({ 368 - "client_id": &client_id, 369 - "client_name": "DPoP Test Client", 370 - "redirect_uris": [redirect_uri], 371 - "grant_types": ["authorization_code", "refresh_token"], 372 - "response_types": ["code"], 373 - "token_endpoint_auth_method": "none", 374 - "dpop_bound_access_tokens": true 375 - }); 376 - Mock::given(method("GET")) 377 - .and(path("/")) 378 - .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 379 - .mount(&mock_server) 380 - .await; 381 - 382 - let (code_verifier, code_challenge) = generate_pkce(); 383 - let par_body: Value = http_client 384 - .post(format!("{}/oauth/par", url)) 385 - .form(&[ 386 - ("response_type", "code"), 387 - ("client_id", &client_id), 388 - ("redirect_uri", redirect_uri), 389 - ("code_challenge", &code_challenge), 390 - ("code_challenge_method", "S256"), 391 - ]) 392 - .send() 393 - .await 394 - .unwrap() 395 - .json() 396 - .await 397 - .unwrap(); 398 - 399 - let request_uri = par_body["request_uri"].as_str().unwrap(); 400 - let auth_res = http_client 401 - .post(format!("{}/oauth/authorize", url)) 402 - .header("Content-Type", "application/json") 403 - .header("Accept", "application/json") 404 - .json(&json!({ 405 - "request_uri": request_uri, 406 - "username": &handle, 407 - "password": "DpopTest123!", 408 - "remember_device": false 409 - })) 410 - .send() 411 - .await 412 - .unwrap(); 413 - let auth_body: Value = auth_res.json().await.unwrap(); 414 - let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 415 - if location.contains("/oauth/consent") { 416 - let consent_res = http_client 417 - .post(format!("{}/oauth/authorize/consent", url)) 418 - .header("Content-Type", "application/json") 419 - .json(&json!({ 420 - "request_uri": request_uri, 421 - "approved_scopes": ["atproto"], 422 - "remember": false 423 - })) 424 - .send() 425 - .await 426 - .unwrap(); 427 - let consent_body: Value = consent_res.json().await.unwrap(); 428 - location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 429 - } 430 - 431 - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 432 - 433 - let token_endpoint = format!("{}/oauth/token", pds_url); 434 - let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None); 435 - 436 - let token_res = http_client 437 - .post(format!("{}/oauth/token", url)) 438 - .header("DPoP", &dpop_proof) 439 - .form(&[ 440 - ("grant_type", "authorization_code"), 441 - ("code", code), 442 - ("redirect_uri", redirect_uri), 443 - ("code_verifier", &code_verifier), 444 - ("client_id", &client_id), 445 - ]) 446 - .send() 447 - .await 448 - .unwrap(); 449 - 450 - let token_status = token_res.status(); 451 - let token_nonce = token_res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap().to_string()); 452 - let token_body: Value = token_res.json().await.unwrap(); 453 - 454 - let access_token = if token_status == StatusCode::OK { 455 - token_body["access_token"].as_str().unwrap().to_string() 456 - } else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") { 457 - let nonce = token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error"); 458 - let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce)); 459 - 460 - let retry_res = http_client 461 - .post(format!("{}/oauth/token", url)) 462 - .header("DPoP", &dpop_proof_with_nonce) 463 - .form(&[ 464 - ("grant_type", "authorization_code"), 465 - ("code", code), 466 - ("redirect_uri", redirect_uri), 467 - ("code_verifier", &code_verifier), 468 - ("client_id", &client_id), 469 - ]) 470 - .send() 471 - .await 472 - .unwrap(); 473 - let retry_body: Value = retry_res.json().await.unwrap(); 474 - retry_body["access_token"].as_str().expect("Should get access_token after nonce retry").to_string() 475 - } else { 476 - panic!("Token exchange failed unexpectedly: {:?}", token_body); 477 - }; 478 - 479 - let res = http_client 480 - .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 481 - .header("Authorization", format!("DPoP {}", access_token)) 482 - .send() 483 - .await 484 - .unwrap(); 485 - 486 - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DPoP token without proof should fail"); 487 - 488 - let www_auth = res.headers().get("www-authenticate").map(|h| h.to_str().unwrap()); 489 - assert!(www_auth.is_some(), "Should have WWW-Authenticate header"); 490 - assert!( 491 - www_auth.unwrap().contains("use_dpop_nonce"), 492 - "WWW-Authenticate should indicate dpop nonce required" 493 - ); 494 - 495 - let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap()); 496 - assert!(nonce.is_some(), "Should return DPoP-Nonce header"); 497 - 498 - let body: Value = res.json().await.unwrap(); 499 - assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce"); 500 - } 501 - 502 - fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) { 503 - use p256::ecdsa::{SigningKey, signature::Signer}; 504 - use p256::elliptic_curve::rand_core::OsRng; 505 - 506 - let signing_key = SigningKey::random(&mut OsRng); 507 - let verifying_key = signing_key.verifying_key(); 508 - let point = verifying_key.to_encoded_point(false); 509 - let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 510 - let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 511 - 512 - let jwk = json!({ 513 - "kty": "EC", 514 - "crv": "P-256", 515 - "x": x, 516 - "y": y 517 - }); 518 - 519 - let header = { 520 - let h = json!({ 521 - "typ": "dpop+jwt", 522 - "alg": "ES256", 523 - "jwk": jwk.clone() 524 - }); 525 - h 526 - }; 527 - 528 - let mut payload = json!({ 529 - "jti": uuid::Uuid::new_v4().to_string(), 530 - "htm": method, 531 - "htu": uri, 532 - "iat": Utc::now().timestamp() 533 - }); 534 - if let Some(n) = nonce { 535 - payload["nonce"] = json!(n); 536 - } 537 - 538 - let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 539 - let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 540 - let signing_input = format!("{}.{}", header_b64, payload_b64); 541 - 542 - let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 543 - let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 544 - 545 - let proof = format!("{}.{}", signing_input, sig_b64); 546 - (jwk, proof) 547 - }
···
+135
crates/tranquil-pds/src/auth/login_identifier.rs
···
··· 1 + use std::fmt; 2 + 3 + #[derive(Debug, Clone, PartialEq, Eq)] 4 + pub struct NormalizedLoginIdentifier(String); 5 + 6 + impl NormalizedLoginIdentifier { 7 + pub fn normalize(identifier: &str, pds_hostname: &str) -> Self { 8 + let trimmed = identifier.trim(); 9 + let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed); 10 + 11 + let normalized = match () { 12 + _ if stripped.starts_with("did:") => stripped.to_string(), 13 + _ if stripped.contains('@') => stripped.to_string(), 14 + _ if !stripped.contains('.') => { 15 + format!("{}.{}", stripped.to_lowercase(), pds_hostname) 16 + } 17 + _ => stripped.to_lowercase(), 18 + }; 19 + 20 + Self(normalized) 21 + } 22 + 23 + pub fn as_str(&self) -> &str { 24 + &self.0 25 + } 26 + } 27 + 28 + impl AsRef<str> for NormalizedLoginIdentifier { 29 + fn as_ref(&self) -> &str { 30 + &self.0 31 + } 32 + } 33 + 34 + impl fmt::Display for NormalizedLoginIdentifier { 35 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 36 + write!(f, "{}", self.0) 37 + } 38 + } 39 + 40 + #[derive(Debug, Clone, PartialEq, Eq)] 41 + pub struct BareLoginIdentifier(String); 42 + 43 + impl BareLoginIdentifier { 44 + pub fn from_identifier(identifier: &str, pds_hostname: &str) -> Self { 45 + let trimmed = identifier.trim(); 46 + let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed); 47 + let suffix = format!(".{}", pds_hostname); 48 + let bare = stripped.strip_suffix(&suffix).unwrap_or(stripped); 49 + Self(bare.to_string()) 50 + } 51 + 52 + pub fn as_str(&self) -> &str { 53 + &self.0 54 + } 55 + } 56 + 57 + impl AsRef<str> for BareLoginIdentifier { 58 + fn as_ref(&self) -> &str { 59 + &self.0 60 + } 61 + } 62 + 63 + impl fmt::Display for BareLoginIdentifier { 64 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 65 + write!(f, "{}", self.0) 66 + } 67 + } 68 + 69 + #[cfg(test)] 70 + mod tests { 71 + use super::*; 72 + 73 + #[test] 74 + fn normalized_identifier_handles_did() { 75 + let id = NormalizedLoginIdentifier::normalize("did:plc:abc123", "example.com"); 76 + assert_eq!(id.as_str(), "did:plc:abc123"); 77 + } 78 + 79 + #[test] 80 + fn normalized_identifier_handles_email() { 81 + let id = NormalizedLoginIdentifier::normalize("user@example.org", "pds.example.com"); 82 + assert_eq!(id.as_str(), "user@example.org"); 83 + } 84 + 85 + #[test] 86 + fn normalized_identifier_handles_bare_handle() { 87 + let id = NormalizedLoginIdentifier::normalize("alice", "pds.example.com"); 88 + assert_eq!(id.as_str(), "alice.pds.example.com"); 89 + } 90 + 91 + #[test] 92 + fn normalized_identifier_handles_bare_handle_with_at_prefix() { 93 + let id = NormalizedLoginIdentifier::normalize("@alice", "pds.example.com"); 94 + assert_eq!(id.as_str(), "alice.pds.example.com"); 95 + } 96 + 97 + #[test] 98 + fn normalized_identifier_handles_full_handle() { 99 + let id = NormalizedLoginIdentifier::normalize("alice.bsky.social", "pds.example.com"); 100 + assert_eq!(id.as_str(), "alice.bsky.social"); 101 + } 102 + 103 + #[test] 104 + fn normalized_identifier_handles_uppercase() { 105 + let id = NormalizedLoginIdentifier::normalize("ALICE", "pds.example.com"); 106 + assert_eq!(id.as_str(), "alice.pds.example.com"); 107 + 108 + let id2 = NormalizedLoginIdentifier::normalize("ALICE.BSKY.SOCIAL", "pds.example.com"); 109 + assert_eq!(id2.as_str(), "alice.bsky.social"); 110 + } 111 + 112 + #[test] 113 + fn normalized_identifier_trims_whitespace() { 114 + let id = NormalizedLoginIdentifier::normalize(" alice ", "pds.example.com"); 115 + assert_eq!(id.as_str(), "alice.pds.example.com"); 116 + } 117 + 118 + #[test] 119 + fn bare_identifier_strips_hostname_suffix() { 120 + let id = BareLoginIdentifier::from_identifier("alice.pds.example.com", "pds.example.com"); 121 + assert_eq!(id.as_str(), "alice"); 122 + } 123 + 124 + #[test] 125 + fn bare_identifier_preserves_non_matching() { 126 + let id = BareLoginIdentifier::from_identifier("alice.bsky.social", "pds.example.com"); 127 + assert_eq!(id.as_str(), "alice.bsky.social"); 128 + } 129 + 130 + #[test] 131 + fn bare_identifier_strips_at_prefix() { 132 + let id = BareLoginIdentifier::from_identifier("@alice.pds.example.com", "pds.example.com"); 133 + assert_eq!(id.as_str(), "alice"); 134 + } 135 + }
+101
crates/tranquil-pds/src/cid_types.rs
···
··· 1 + use cid::Cid; 2 + use std::fmt; 3 + use std::str::FromStr; 4 + 5 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 6 + pub struct CommitCid(Cid); 7 + 8 + impl CommitCid { 9 + pub fn new(cid: Cid) -> Self { 10 + Self(cid) 11 + } 12 + 13 + pub fn as_cid(&self) -> &Cid { 14 + &self.0 15 + } 16 + 17 + pub fn into_cid(self) -> Cid { 18 + self.0 19 + } 20 + } 21 + 22 + impl From<Cid> for CommitCid { 23 + fn from(cid: Cid) -> Self { 24 + Self(cid) 25 + } 26 + } 27 + 28 + impl From<CommitCid> for Cid { 29 + fn from(commit_cid: CommitCid) -> Self { 30 + commit_cid.0 31 + } 32 + } 33 + 34 + impl FromStr for CommitCid { 35 + type Err = cid::Error; 36 + 37 + fn from_str(s: &str) -> Result<Self, Self::Err> { 38 + Cid::from_str(s).map(Self) 39 + } 40 + } 41 + 42 + impl fmt::Display for CommitCid { 43 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 44 + write!(f, "{}", self.0) 45 + } 46 + } 47 + 48 + impl AsRef<Cid> for CommitCid { 49 + fn as_ref(&self) -> &Cid { 50 + &self.0 51 + } 52 + } 53 + 54 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 55 + pub struct RecordCid(Cid); 56 + 57 + impl RecordCid { 58 + pub fn new(cid: Cid) -> Self { 59 + Self(cid) 60 + } 61 + 62 + pub fn as_cid(&self) -> &Cid { 63 + &self.0 64 + } 65 + 66 + pub fn into_cid(self) -> Cid { 67 + self.0 68 + } 69 + } 70 + 71 + impl From<Cid> for RecordCid { 72 + fn from(cid: Cid) -> Self { 73 + Self(cid) 74 + } 75 + } 76 + 77 + impl From<RecordCid> for Cid { 78 + fn from(record_cid: RecordCid) -> Self { 79 + record_cid.0 80 + } 81 + } 82 + 83 + impl FromStr for RecordCid { 84 + type Err = cid::Error; 85 + 86 + fn from_str(s: &str) -> Result<Self, Self::Err> { 87 + Cid::from_str(s).map(Self) 88 + } 89 + } 90 + 91 + impl fmt::Display for RecordCid { 92 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 93 + write!(f, "{}", self.0) 94 + } 95 + } 96 + 97 + impl AsRef<Cid> for RecordCid { 98 + fn as_ref(&self) -> &Cid { 99 + &self.0 100 + } 101 + }
+6 -6
crates/tranquil-pds/src/comms/service.rs
··· 278 &[("hostname", hostname), ("handle", &prefs.handle)], 279 ); 280 let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]); 281 - let channel = channel_from_str(&prefs.preferred_channel); 282 infra_repo 283 .enqueue_comms( 284 Some(user_id), ··· 309 &[("handle", &prefs.handle), ("code", code)], 310 ); 311 let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]); 312 - let channel = channel_from_str(&prefs.preferred_channel); 313 infra_repo 314 .enqueue_comms( 315 Some(user_id), ··· 422 &[("handle", &prefs.handle), ("code", code)], 423 ); 424 let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]); 425 - let channel = channel_from_str(&prefs.preferred_channel); 426 infra_repo 427 .enqueue_comms( 428 Some(user_id), ··· 453 &[("handle", &prefs.handle), ("token", token)], 454 ); 455 let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]); 456 - let channel = channel_from_str(&prefs.preferred_channel); 457 infra_repo 458 .enqueue_comms( 459 Some(user_id), ··· 484 &[("handle", &prefs.handle), ("url", recovery_url)], 485 ); 486 let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]); 487 - let channel = channel_from_str(&prefs.preferred_channel); 488 infra_repo 489 .enqueue_comms( 490 Some(user_id), ··· 614 &[("handle", &prefs.handle), ("code", code)], 615 ); 616 let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]); 617 - let channel = channel_from_str(&prefs.preferred_channel); 618 infra_repo 619 .enqueue_comms( 620 Some(user_id),
··· 278 &[("hostname", hostname), ("handle", &prefs.handle)], 279 ); 280 let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]); 281 + let channel = prefs.preferred_channel; 282 infra_repo 283 .enqueue_comms( 284 Some(user_id), ··· 309 &[("handle", &prefs.handle), ("code", code)], 310 ); 311 let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]); 312 + let channel = prefs.preferred_channel; 313 infra_repo 314 .enqueue_comms( 315 Some(user_id), ··· 422 &[("handle", &prefs.handle), ("code", code)], 423 ); 424 let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]); 425 + let channel = prefs.preferred_channel; 426 infra_repo 427 .enqueue_comms( 428 Some(user_id), ··· 453 &[("handle", &prefs.handle), ("token", token)], 454 ); 455 let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]); 456 + let channel = prefs.preferred_channel; 457 infra_repo 458 .enqueue_comms( 459 Some(user_id), ··· 484 &[("handle", &prefs.handle), ("url", recovery_url)], 485 ); 486 let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]); 487 + let channel = prefs.preferred_channel; 488 infra_repo 489 .enqueue_comms( 490 Some(user_id), ··· 614 &[("handle", &prefs.handle), ("code", code)], 615 ); 616 let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]); 617 + let channel = prefs.preferred_channel; 618 infra_repo 619 .enqueue_comms( 620 Some(user_id),
+5 -29
crates/tranquil-pds/src/delegation/scopes.rs
··· 1 use std::collections::HashSet; 2 3 pub struct ScopePreset { 4 pub name: &'static str, 5 pub label: &'static str, ··· 107 } 108 } 109 110 - pub fn validate_delegation_scopes(scopes: &str) -> Result<(), String> { 111 - if scopes.is_empty() { 112 - return Ok(()); 113 - } 114 - 115 - scopes.split_whitespace().try_for_each(|scope| { 116 - let (base, _) = split_scope(scope); 117 - if is_valid_scope_prefix(base) { 118 - Ok(()) 119 - } else { 120 - Err(format!("Invalid scope: {}", scope)) 121 - } 122 - }) 123 - } 124 - 125 - fn is_valid_scope_prefix(base: &str) -> bool { 126 - const VALID_PREFIXES: [&str; 7] = [ 127 - "atproto", 128 - "repo:", 129 - "blob:", 130 - "rpc:", 131 - "account:", 132 - "identity:", 133 - "transition:", 134 - ]; 135 - 136 - VALID_PREFIXES 137 - .iter() 138 - .any(|prefix| base == prefix.trim_end_matches(':') || base.starts_with(prefix)) 139 } 140 141 #[cfg(test)]
··· 1 use std::collections::HashSet; 2 3 + pub use tranquil_db_traits::{DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError}; 4 + 5 pub struct ScopePreset { 6 pub name: &'static str, 7 pub label: &'static str, ··· 109 } 110 } 111 112 + pub fn validate_delegation_scopes(scopes: &str) -> Result<(), InvalidDelegationScopeError> { 113 + ValidatedDelegationScope::new(scopes)?; 114 + Ok(()) 115 } 116 117 #[cfg(test)]
+2 -1
crates/tranquil-pds/src/lib.rs
··· 2 pub mod appview; 3 pub mod auth; 4 pub mod cache; 5 pub mod circuit_breaker; 6 pub mod comms; 7 pub mod config; ··· 35 use http::StatusCode; 36 use serde_json::json; 37 use state::AppState; 38 - pub use sync::util::AccountStatus; 39 use tower::ServiceBuilder; 40 use tower_http::cors::{Any, CorsLayer}; 41 pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; 42 43 pub fn app(state: AppState) -> Router {
··· 2 pub mod appview; 3 pub mod auth; 4 pub mod cache; 5 + pub mod cid_types; 6 pub mod circuit_breaker; 7 pub mod comms; 8 pub mod config; ··· 36 use http::StatusCode; 37 use serde_json::json; 38 use state::AppState; 39 use tower::ServiceBuilder; 40 use tower_http::cors::{Any, CorsLayer}; 41 + pub use tranquil_db_traits::AccountStatus; 42 pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; 43 44 pub fn app(state: AppState) -> Router {
+5 -5
crates/tranquil-pds/src/scheduled.rs
··· 9 use tokio::time::interval; 10 use tracing::{debug, error, info, warn}; 11 use tranquil_db_traits::{ 12 - BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SsoRepository, 13 - UserRepository, 14 }; 15 use tranquil_types::{AtUri, CidLink, Did}; 16 ··· 22 repo_repo: &dyn RepoRepository, 23 block_store: &PostgresBlockStore, 24 row: BrokenGenesisCommit, 25 - ) -> Result<(Did, i64), (i64, &'static str)> { 26 let commit_cid_str = row.commit_cid.ok_or((row.seq, "missing commit_cid"))?; 27 let commit_cid = Cid::from_str(&commit_cid_str).map_err(|_| (row.seq, "invalid CID"))?; 28 let block = block_store ··· 73 74 let (success, failed) = results.iter().fold((0, 0), |(s, f), r| match r { 75 Ok((did, seq)) => { 76 - info!(seq = seq, did = %did, "Fixed genesis commit blocks_cids"); 77 (s + 1, f) 78 } 79 Err((seq, reason)) => { 80 warn!( 81 - seq = seq, 82 reason = reason, 83 "Failed to process genesis commit" 84 );
··· 9 use tokio::time::interval; 10 use tracing::{debug, error, info, warn}; 11 use tranquil_db_traits::{ 12 + BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SequenceNumber, 13 + SsoRepository, UserRepository, 14 }; 15 use tranquil_types::{AtUri, CidLink, Did}; 16 ··· 22 repo_repo: &dyn RepoRepository, 23 block_store: &PostgresBlockStore, 24 row: BrokenGenesisCommit, 25 + ) -> Result<(Did, SequenceNumber), (SequenceNumber, &'static str)> { 26 let commit_cid_str = row.commit_cid.ok_or((row.seq, "missing commit_cid"))?; 27 let commit_cid = Cid::from_str(&commit_cid_str).map_err(|_| (row.seq, "invalid CID"))?; 28 let block = block_store ··· 73 74 let (success, failed) = results.iter().fold((0, 0), |(s, f), r| match r { 75 Ok((did, seq)) => { 76 + info!(seq = seq.as_i64(), did = %did, "Fixed genesis commit blocks_cids"); 77 (s + 1, f) 78 } 79 Err((seq, reason)) => { 80 warn!( 81 + seq = seq.as_i64(), 82 reason = reason, 83 "Failed to process genesis commit" 84 );
+4 -3
crates/tranquil-pds/src/sync/commit.rs
··· 1 use crate::api::error::ApiError; 2 use crate::state::AppState; 3 - use crate::sync::util::{AccountStatus, assert_repo_availability, get_account_with_status}; 4 use axum::{ 5 Json, 6 extract::{Query, State}, ··· 13 use serde::{Deserialize, Serialize}; 14 use std::str::FromStr; 15 use tracing::error; 16 use tranquil_types::Did; 17 18 async fn get_rev_from_commit(state: &AppState, cid_str: &str) -> Option<String> { ··· 130 head: cid_str, 131 rev, 132 active: status.is_active(), 133 - status: status.as_str().map(String::from), 134 }); 135 } 136 let next_cursor = if has_more { ··· 212 Json(GetRepoStatusOutput { 213 did: account.did, 214 active: account.status.is_active(), 215 - status: account.status.as_str().map(String::from), 216 rev, 217 }), 218 )
··· 1 use crate::api::error::ApiError; 2 use crate::state::AppState; 3 + use crate::sync::util::{assert_repo_availability, get_account_with_status}; 4 use axum::{ 5 Json, 6 extract::{Query, State}, ··· 13 use serde::{Deserialize, Serialize}; 14 use std::str::FromStr; 15 use tracing::error; 16 + use tranquil_db_traits::AccountStatus; 17 use tranquil_types::Did; 18 19 async fn get_rev_from_commit(state: &AppState, cid_str: &str) -> Option<String> { ··· 131 head: cid_str, 132 rev, 133 active: status.is_active(), 134 + status: status.for_firehose().map(String::from), 135 }); 136 } 137 let next_cursor = if has_more { ··· 213 Json(GetRepoStatusOutput { 214 did: account.did, 215 active: account.status.is_active(), 216 + status: account.status.for_firehose().map(String::from), 217 rev, 218 }), 219 )
+10 -7
crates/tranquil-pds/src/sync/frame.rs
··· 2 use cid::Cid; 3 use serde::{Deserialize, Serialize}; 4 use std::str::FromStr; 5 6 #[derive(Debug, Serialize, Deserialize)] 7 pub struct FrameHeader { ··· 38 39 #[derive(Debug, Serialize, Deserialize)] 40 pub struct RepoOp { 41 - pub action: String, 42 pub path: String, 43 pub cid: Option<Cid>, 44 #[serde(skip_serializing_if = "Option::is_none")] ··· 159 serde_json::from_value(self.ops_json).unwrap_or_else(|_| vec![]); 160 let ops: Vec<RepoOp> = json_ops 161 .into_iter() 162 - .map(|op| RepoOp { 163 - action: op.action, 164 - path: op.path, 165 - cid: op.cid.and_then(|s| Cid::from_str(&s).ok()), 166 - prev: op.prev.and_then(|s| Cid::from_str(&s).ok()), 167 }) 168 .collect(); 169 let rev = self.rev.unwrap_or_else(placeholder_rev); ··· 202 CommitFrameError::InvalidCommitCid("Missing commit_cid in event".to_string()) 203 })?; 204 let builder = CommitFrameBuilder::new( 205 - event.seq, 206 event.did.to_string(), 207 commit_cid.as_str(), 208 event.prev_cid.as_ref().map(|c| c.as_str()),
··· 2 use cid::Cid; 3 use serde::{Deserialize, Serialize}; 4 use std::str::FromStr; 5 + use tranquil_scopes::RepoAction; 6 7 #[derive(Debug, Serialize, Deserialize)] 8 pub struct FrameHeader { ··· 39 40 #[derive(Debug, Serialize, Deserialize)] 41 pub struct RepoOp { 42 + pub action: RepoAction, 43 pub path: String, 44 pub cid: Option<Cid>, 45 #[serde(skip_serializing_if = "Option::is_none")] ··· 160 serde_json::from_value(self.ops_json).unwrap_or_else(|_| vec![]); 161 let ops: Vec<RepoOp> = json_ops 162 .into_iter() 163 + .filter_map(|op| { 164 + Some(RepoOp { 165 + action: RepoAction::parse_str(&op.action)?, 166 + path: op.path, 167 + cid: op.cid.and_then(|s| Cid::from_str(&s).ok()), 168 + prev: op.prev.and_then(|s| Cid::from_str(&s).ok()), 169 + }) 170 }) 171 .collect(); 172 let rev = self.rev.unwrap_or_else(placeholder_rev); ··· 205 CommitFrameError::InvalidCommitCid("Missing commit_cid in event".to_string()) 206 })?; 207 let builder = CommitFrameBuilder::new( 208 + event.seq.as_i64(), 209 event.did.to_string(), 210 commit_cid.as_str(), 211 event.prev_cid.as_ref().map(|c| c.as_str()),
+21 -10
crates/tranquil-pds/src/sync/listener.rs
··· 2 use crate::sync::firehose::SequencedEvent; 3 use std::sync::atomic::{AtomicI64, Ordering}; 4 use tracing::{debug, error, info, warn}; 5 6 static LAST_BROADCAST_SEQ: AtomicI64 = AtomicI64::new(0); 7 8 pub async fn start_sequencer_listener(state: AppState) { 9 - let initial_seq = state.repo_repo.get_max_seq().await.unwrap_or(0); 10 - LAST_BROADCAST_SEQ.store(initial_seq, Ordering::SeqCst); 11 - info!(initial_seq = initial_seq, "Initialized sequencer listener"); 12 tokio::spawn(async move { 13 info!("Starting sequencer listener background task"); 14 loop { ··· 27 .await 28 .map_err(|e| anyhow::anyhow!("Failed to subscribe to events: {:?}", e))?; 29 info!("Connected to database and listening for repo updates"); 30 - let catchup_start = LAST_BROADCAST_SEQ.load(Ordering::SeqCst); 31 let events = state 32 .repo_repo 33 .get_events_since_seq(catchup_start, None) ··· 36 if !events.is_empty() { 37 info!( 38 count = events.len(), 39 - from_seq = catchup_start, 40 "Broadcasting catch-up events" 41 ); 42 events.into_iter().for_each(|event| { 43 let seq = event.seq; 44 let firehose_event = to_firehose_event(event); 45 let _ = state.firehose_tx.send(firehose_event); 46 - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); 47 }); 48 } 49 loop { ··· 63 if seq_id > last_seq + 1 { 64 let gap_events = state 65 .repo_repo 66 - .get_events_in_seq_range(last_seq, seq_id) 67 .await 68 .unwrap_or_default(); 69 if !gap_events.is_empty() { ··· 72 let seq = event.seq; 73 let firehose_event = to_firehose_event(event); 74 let _ = state.firehose_tx.send(firehose_event); 75 - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); 76 }); 77 } 78 } 79 let event = state 80 .repo_repo 81 - .get_event_by_seq(seq_id) 82 .await 83 .ok() 84 .flatten(); ··· 97 warn!(seq = seq_id, error = %e, "Failed to broadcast event (no receivers?)"); 98 } 99 } 100 - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); 101 } else { 102 warn!( 103 seq = seq_id,
··· 2 use crate::sync::firehose::SequencedEvent; 3 use std::sync::atomic::{AtomicI64, Ordering}; 4 use tracing::{debug, error, info, warn}; 5 + use tranquil_db_traits::SequenceNumber; 6 7 static LAST_BROADCAST_SEQ: AtomicI64 = AtomicI64::new(0); 8 9 pub async fn start_sequencer_listener(state: AppState) { 10 + let initial_seq = state 11 + .repo_repo 12 + .get_max_seq() 13 + .await 14 + .unwrap_or(SequenceNumber::ZERO); 15 + LAST_BROADCAST_SEQ.store(initial_seq.as_i64(), Ordering::SeqCst); 16 + info!( 17 + initial_seq = initial_seq.as_i64(), 18 + "Initialized sequencer listener" 19 + ); 20 tokio::spawn(async move { 21 info!("Starting sequencer listener background task"); 22 loop { ··· 35 .await 36 .map_err(|e| anyhow::anyhow!("Failed to subscribe to events: {:?}", e))?; 37 info!("Connected to database and listening for repo updates"); 38 + let catchup_start = SequenceNumber::from_raw(LAST_BROADCAST_SEQ.load(Ordering::SeqCst)); 39 let events = state 40 .repo_repo 41 .get_events_since_seq(catchup_start, None) ··· 44 if !events.is_empty() { 45 info!( 46 count = events.len(), 47 + from_seq = catchup_start.as_i64(), 48 "Broadcasting catch-up events" 49 ); 50 events.into_iter().for_each(|event| { 51 let seq = event.seq; 52 let firehose_event = to_firehose_event(event); 53 let _ = state.firehose_tx.send(firehose_event); 54 + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); 55 }); 56 } 57 loop { ··· 71 if seq_id > last_seq + 1 { 72 let gap_events = state 73 .repo_repo 74 + .get_events_in_seq_range( 75 + SequenceNumber::from_raw(last_seq), 76 + SequenceNumber::from_raw(seq_id), 77 + ) 78 .await 79 .unwrap_or_default(); 80 if !gap_events.is_empty() { ··· 83 let seq = event.seq; 84 let firehose_event = to_firehose_event(event); 85 let _ = state.firehose_tx.send(firehose_event); 86 + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); 87 }); 88 } 89 } 90 let event = state 91 .repo_repo 92 + .get_event_by_seq(SequenceNumber::from_raw(seq_id)) 93 .await 94 .ok() 95 .flatten(); ··· 108 warn!(seq = seq_id, error = %e, "Failed to broadcast event (no receivers?)"); 109 } 110 } 111 + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); 112 } else { 113 warn!( 114 seq = seq_id,
+2 -2
crates/tranquil-pds/src/sync/mod.rs
··· 18 pub use deprecated::{get_checkout, get_head}; 19 pub use repo::{get_blocks, get_record, get_repo}; 20 pub use subscribe_repos::subscribe_repos; 21 pub use util::{ 22 - AccountStatus, RepoAccount, RepoAvailabilityError, assert_repo_availability, 23 - get_account_with_status, 24 }; 25 pub use verify::{CarVerifier, VerifiedCar, VerifyError};
··· 18 pub use deprecated::{get_checkout, get_head}; 19 pub use repo::{get_blocks, get_record, get_repo}; 20 pub use subscribe_repos::subscribe_repos; 21 + pub use tranquil_db_traits::AccountStatus; 22 pub use util::{ 23 + RepoAccount, RepoAvailabilityError, assert_repo_availability, get_account_with_status, 24 }; 25 pub use verify::{CarVerifier, VerifiedCar, VerifyError};
+12 -6
crates/tranquil-pds/src/sync/subscribe_repos.rs
··· 13 use std::sync::atomic::{AtomicUsize, Ordering}; 14 use tokio::sync::broadcast::error::RecvError; 15 use tracing::{error, info, warn}; 16 17 const BACKFILL_BATCH_SIZE: i64 = 1000; 18 ··· 69 params: SubscribeReposParams, 70 ) -> Result<(), ()> { 71 let mut rx = state.firehose_tx.subscribe(); 72 - let mut last_seen: i64 = -1; 73 74 if let Some(cursor) = params.cursor { 75 - let current_seq = state.repo_repo.get_max_seq().await.unwrap_or(0); 76 77 - if cursor > current_seq { 78 if let Ok(error_bytes) = 79 format_error_frame("FutureCursor", Some("Cursor in the future.")) 80 { ··· 88 89 let first_event = state 90 .repo_repo 91 - .get_events_since_cursor(cursor, 1) 92 .await 93 .ok() 94 .and_then(|events| events.into_iter().next()); 95 96 - let mut current_cursor = cursor; 97 98 if let Some(ref event) = first_event 99 && event.created_at < backfill_time ··· 113 .flatten(); 114 115 if let Some(earliest_seq) = earliest { 116 - current_cursor = earliest_seq - 1; 117 } 118 } 119
··· 13 use std::sync::atomic::{AtomicUsize, Ordering}; 14 use tokio::sync::broadcast::error::RecvError; 15 use tracing::{error, info, warn}; 16 + use tranquil_db_traits::SequenceNumber; 17 18 const BACKFILL_BATCH_SIZE: i64 = 1000; 19 ··· 70 params: SubscribeReposParams, 71 ) -> Result<(), ()> { 72 let mut rx = state.firehose_tx.subscribe(); 73 + let mut last_seen = SequenceNumber::UNSET; 74 75 if let Some(cursor) = params.cursor { 76 + let cursor_seq = SequenceNumber::from_raw(cursor); 77 + let current_seq = state 78 + .repo_repo 79 + .get_max_seq() 80 + .await 81 + .unwrap_or(SequenceNumber::ZERO); 82 83 + if cursor_seq > current_seq { 84 if let Ok(error_bytes) = 85 format_error_frame("FutureCursor", Some("Cursor in the future.")) 86 { ··· 94 95 let first_event = state 96 .repo_repo 97 + .get_events_since_cursor(cursor_seq, 1) 98 .await 99 .ok() 100 .and_then(|events| events.into_iter().next()); 101 102 + let mut current_cursor = cursor_seq; 103 104 if let Some(ref event) = first_event 105 && event.created_at < backfill_time ··· 119 .flatten(); 120 121 if let Some(earliest_seq) = earliest { 122 + current_cursor = SequenceNumber::from_raw(earliest_seq.as_i64() - 1); 123 } 124 } 125
+18 -102
crates/tranquil-pds/src/sync/util.rs
··· 11 use iroh_car::{CarHeader, CarWriter}; 12 use jacquard_repo::commit::Commit; 13 use jacquard_repo::storage::BlockStore; 14 - use serde::Serialize; 15 use std::collections::{BTreeMap, HashMap}; 16 use std::io::Cursor; 17 use std::str::FromStr; 18 use tokio::io::AsyncWriteExt; 19 - use tranquil_db_traits::RepoRepository; 20 use tranquil_types::Did; 21 22 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] 23 - #[serde(rename_all = "lowercase")] 24 - pub enum AccountStatus { 25 - Active, 26 - Takendown, 27 - Suspended, 28 - Deactivated, 29 - Deleted, 30 - } 31 - 32 - impl AccountStatus { 33 - pub fn as_str(&self) -> Option<&'static str> { 34 - match self { 35 - Self::Active => None, 36 - Self::Takendown => Some("takendown"), 37 - Self::Suspended => Some("suspended"), 38 - Self::Deactivated => Some("deactivated"), 39 - Self::Deleted => Some("deleted"), 40 - } 41 - } 42 - 43 - pub fn is_active(&self) -> bool { 44 - matches!(self, Self::Active) 45 - } 46 - 47 - pub fn is_takendown(&self) -> bool { 48 - matches!(self, Self::Takendown) 49 - } 50 - 51 - pub fn is_suspended(&self) -> bool { 52 - matches!(self, Self::Suspended) 53 - } 54 - 55 - pub fn is_deactivated(&self) -> bool { 56 - matches!(self, Self::Deactivated) 57 - } 58 - 59 - pub fn is_deleted(&self) -> bool { 60 - matches!(self, Self::Deleted) 61 - } 62 - 63 - pub fn allows_read(&self) -> bool { 64 - matches!(self, Self::Active | Self::Deactivated) 65 - } 66 - 67 - pub fn allows_write(&self) -> bool { 68 - matches!(self, Self::Active) 69 - } 70 - 71 - pub fn from_db_fields( 72 - takedown_ref: Option<&str>, 73 - deactivated_at: Option<chrono::DateTime<chrono::Utc>>, 74 - ) -> Self { 75 - if takedown_ref.is_some() { 76 - Self::Takendown 77 - } else if deactivated_at.is_some() { 78 - Self::Deactivated 79 - } else { 80 - Self::Active 81 - } 82 - } 83 - } 84 - 85 - impl From<crate::types::AccountState> for AccountStatus { 86 - fn from(state: crate::types::AccountState) -> Self { 87 - match state { 88 - crate::types::AccountState::Active => AccountStatus::Active, 89 - crate::types::AccountState::Deactivated { .. } => AccountStatus::Deactivated, 90 - crate::types::AccountState::TakenDown { .. } => AccountStatus::Takendown, 91 - crate::types::AccountState::Migrated { .. } => AccountStatus::Deactivated, 92 - } 93 - } 94 - } 95 - 96 - impl From<&crate::types::AccountState> for AccountStatus { 97 - fn from(state: &crate::types::AccountState) -> Self { 98 - match state { 99 - crate::types::AccountState::Active => AccountStatus::Active, 100 - crate::types::AccountState::Deactivated { .. } => AccountStatus::Deactivated, 101 - crate::types::AccountState::TakenDown { .. } => AccountStatus::Takendown, 102 - crate::types::AccountState::Migrated { .. } => AccountStatus::Deactivated, 103 - } 104 - } 105 - } 106 - 107 pub struct RepoAccount { 108 pub did: String, 109 pub user_id: uuid::Uuid, ··· 233 let frame = IdentityFrame { 234 did: event.did.to_string(), 235 handle: event.handle.as_ref().map(|h| h.to_string()), 236 - seq: event.seq, 237 time: format_atproto_time(event.created_at), 238 }; 239 let header = FrameHeader { ··· 250 let frame = AccountFrame { 251 did: event.did.to_string(), 252 active: event.active.unwrap_or(true), 253 - status: event.status.clone(), 254 - seq: event.seq, 255 time: format_atproto_time(event.created_at), 256 }; 257 let header = FrameHeader { ··· 298 did: event.did.to_string(), 299 rev, 300 blocks: car_bytes, 301 - seq: event.seq, 302 time: format_atproto_time(event.created_at), 303 }; 304 let header = FrameHeader { ··· 315 state: &AppState, 316 event: SequencedEvent, 317 ) -> Result<Vec<u8>, anyhow::Error> { 318 - match event.event_type.as_str() { 319 - "identity" => return format_identity_event(&event), 320 - "account" => return format_account_event(&event), 321 - "sync" => return format_sync_event(state, &event).await, 322 - _ => {} 323 } 324 let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); 325 let prev_cid_link = event.prev_cid.clone(); ··· 440 did: event.did.to_string(), 441 rev, 442 blocks: car_bytes, 443 - seq: event.seq, 444 time: format_atproto_time(event.created_at), 445 }; 446 let header = FrameHeader { ··· 457 event: SequencedEvent, 458 prefetched: &HashMap<Cid, Bytes>, 459 ) -> Result<Vec<u8>, anyhow::Error> { 460 - match event.event_type.as_str() { 461 - "identity" => return format_identity_event(&event), 462 - "account" => return format_account_event(&event), 463 - "sync" => return format_sync_event_with_prefetched(&event, prefetched), 464 - _ => {} 465 } 466 let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); 467 let prev_cid_link = event.prev_cid.clone();
··· 11 use iroh_car::{CarHeader, CarWriter}; 12 use jacquard_repo::commit::Commit; 13 use jacquard_repo::storage::BlockStore; 14 use std::collections::{BTreeMap, HashMap}; 15 use std::io::Cursor; 16 use std::str::FromStr; 17 use tokio::io::AsyncWriteExt; 18 + use tranquil_db_traits::{AccountStatus, RepoEventType, RepoRepository}; 19 use tranquil_types::Did; 20 21 pub struct RepoAccount { 22 pub did: String, 23 pub user_id: uuid::Uuid, ··· 147 let frame = IdentityFrame { 148 did: event.did.to_string(), 149 handle: event.handle.as_ref().map(|h| h.to_string()), 150 + seq: event.seq.as_i64(), 151 time: format_atproto_time(event.created_at), 152 }; 153 let header = FrameHeader { ··· 164 let frame = AccountFrame { 165 did: event.did.to_string(), 166 active: event.active.unwrap_or(true), 167 + status: event 168 + .status 169 + .and_then(|s| s.for_firehose().map(String::from)), 170 + seq: event.seq.as_i64(), 171 time: format_atproto_time(event.created_at), 172 }; 173 let header = FrameHeader { ··· 214 did: event.did.to_string(), 215 rev, 216 blocks: car_bytes, 217 + seq: event.seq.as_i64(), 218 time: format_atproto_time(event.created_at), 219 }; 220 let header = FrameHeader { ··· 231 state: &AppState, 232 event: SequencedEvent, 233 ) -> Result<Vec<u8>, anyhow::Error> { 234 + match event.event_type { 235 + RepoEventType::Identity => return format_identity_event(&event), 236 + RepoEventType::Account => return format_account_event(&event), 237 + RepoEventType::Sync => return format_sync_event(state, &event).await, 238 + RepoEventType::Commit => {} 239 } 240 let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); 241 let prev_cid_link = event.prev_cid.clone(); ··· 356 did: event.did.to_string(), 357 rev, 358 blocks: car_bytes, 359 + seq: event.seq.as_i64(), 360 time: format_atproto_time(event.created_at), 361 }; 362 let header = FrameHeader { ··· 373 event: SequencedEvent, 374 prefetched: &HashMap<Cid, Bytes>, 375 ) -> Result<Vec<u8>, anyhow::Error> { 376 + match event.event_type { 377 + RepoEventType::Identity => return format_identity_event(&event), 378 + RepoEventType::Account => return format_account_event(&event), 379 + RepoEventType::Sync => return format_sync_event_with_prefetched(&event, prefetched), 380 + RepoEventType::Commit => {} 381 } 382 let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); 383 let prev_cid_link = event.prev_cid.clone();
+6 -10
crates/tranquil-pds/tests/firehose_validation.rs
··· 9 use serde_json::{Value, json}; 10 use std::io::Cursor; 11 use tokio_tungstenite::{connect_async, tungstenite}; 12 13 #[derive(Debug, Deserialize, Serialize)] 14 struct FrameHeader { ··· 39 40 #[derive(Debug, Deserialize)] 41 struct RepoOp { 42 - action: String, 43 path: String, 44 cid: Option<Cid>, 45 prev: Option<Cid>, ··· 292 println!("\nOps validation:"); 293 for (i, op) in frame.ops.iter().enumerate() { 294 println!(" Op {}:", i); 295 - println!(" action: {}", op.action); 296 println!(" path: {}", op.path); 297 println!(" cid: {:?}", op.cid); 298 println!( ··· 300 op.prev 301 ); 302 303 - assert!( 304 - ["create", "update", "delete"].contains(&op.action.as_str()), 305 - "Invalid action: {}", 306 - op.action 307 - ); 308 assert!( 309 op.path.contains('/'), 310 "Path should contain collection/rkey: {}", 311 op.path 312 ); 313 314 - if op.action == "create" { 315 assert!(op.cid.is_some(), "Create op should have cid"); 316 } 317 } ··· 445 446 for op in &frame.ops { 447 println!( 448 - "Op: action={}, path={}, cid={:?}, prev={:?}", 449 op.action, op.path, op.cid, op.prev 450 ); 451 452 - if op.action == "update" && op.path.contains("app.bsky.actor.profile") { 453 assert!( 454 op.prev.is_some(), 455 "Update operation should have 'prev' field with old CID! Got: {:?}",
··· 9 use serde_json::{Value, json}; 10 use std::io::Cursor; 11 use tokio_tungstenite::{connect_async, tungstenite}; 12 + use tranquil_scopes::RepoAction; 13 14 #[derive(Debug, Deserialize, Serialize)] 15 struct FrameHeader { ··· 40 41 #[derive(Debug, Deserialize)] 42 struct RepoOp { 43 + action: RepoAction, 44 path: String, 45 cid: Option<Cid>, 46 prev: Option<Cid>, ··· 293 println!("\nOps validation:"); 294 for (i, op) in frame.ops.iter().enumerate() { 295 println!(" Op {}:", i); 296 + println!(" action: {:?}", op.action); 297 println!(" path: {}", op.path); 298 println!(" cid: {:?}", op.cid); 299 println!( ··· 301 op.prev 302 ); 303 304 assert!( 305 op.path.contains('/'), 306 "Path should contain collection/rkey: {}", 307 op.path 308 ); 309 310 + if op.action == RepoAction::Create { 311 assert!(op.cid.is_some(), "Create op should have cid"); 312 } 313 } ··· 441 442 for op in &frame.ops { 443 println!( 444 + "Op: action={:?}, path={}, cid={:?}, prev={:?}", 445 op.action, op.path, op.cid, op.prev 446 ); 447 448 + if op.action == RepoAction::Update && op.path.contains("app.bsky.actor.profile") { 449 assert!( 450 op.prev.is_some(), 451 "Update operation should have 'prev' field with old CID! Got: {:?}",
+3 -1
crates/tranquil-scopes/src/parser.rs
··· 1 use std::collections::{HashMap, HashSet}; 2 3 #[derive(Debug, Clone, PartialEq, Eq)] ··· 27 pub actions: HashSet<RepoAction>, 28 } 29 30 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 31 pub enum RepoAction { 32 Create, 33 Update,
··· 1 + use serde::{Deserialize, Serialize}; 2 use std::collections::{HashMap, HashSet}; 3 4 #[derive(Debug, Clone, PartialEq, Eq)] ··· 28 pub actions: HashSet<RepoAction>, 29 } 30 31 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 32 + #[serde(rename_all = "lowercase")] 33 pub enum RepoAction { 34 Create, 35 Update,

History

3 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
fix: better type-safety
expand 0 comments
pull request successfully merged
lewis.moe submitted #1
1 commit
expand
fix: better type-safety
expand 0 comments
lewis.moe submitted #0
1 commit
expand
fix: better type-safety
expand 0 comments