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 4 use sqlx::PgPool; 5 5 use tranquil_db_traits::{ 6 6 DbError, DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, 7 - ScopePreference, TrustedDeviceRow, TwoFactorChallenge, 7 + ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, 8 8 }; 9 9 use tranquil_oauth::{ 10 10 AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, Code as OAuthCode, 11 - DeviceData, DeviceId as OAuthDeviceId, RequestData, SessionId as OAuthSessionId, TokenData, 12 - TokenId as OAuthTokenId, RefreshToken as OAuthRefreshToken, 11 + DeviceData, DeviceId as OAuthDeviceId, RefreshToken as OAuthRefreshToken, RequestData, 12 + SessionId as OAuthSessionId, TokenData, TokenId as OAuthTokenId, 13 13 }; 14 14 use tranquil_types::{ 15 15 AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId, ··· 49 49 50 50 #[async_trait] 51 51 impl OAuthRepository for PostgresOAuthRepository { 52 - async fn create_token(&self, data: &TokenData) -> Result<i32, DbError> { 52 + async fn create_token(&self, data: &TokenData) -> Result<TokenFamilyId, DbError> { 53 53 let client_auth_json = to_json(&data.client_auth)?; 54 54 let parameters_json = to_json(&data.parameters)?; 55 55 let row = sqlx::query!( ··· 78 78 .fetch_one(&self.pool) 79 79 .await 80 80 .map_err(map_sqlx_error)?; 81 - Ok(row.id) 81 + Ok(TokenFamilyId::new(row.id)) 82 82 } 83 83 84 84 async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError> { ··· 96 96 .map_err(map_sqlx_error)?; 97 97 match row { 98 98 Some(r) => Ok(Some(TokenData { 99 - did: r.did.parse().map_err(|_| DbError::Other("Invalid DID in token".into()))?, 99 + did: r 100 + .did 101 + .parse() 102 + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, 100 103 token_id: OAuthTokenId(r.token_id), 101 104 created_at: r.created_at, 102 105 updated_at: r.updated_at, ··· 109 112 code: r.code.map(OAuthCode), 110 113 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 111 114 scope: r.scope, 112 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID".into()))?, 115 + controller_did: r 116 + .controller_did 117 + .map(|s| s.parse()) 118 + .transpose() 119 + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, 113 120 })), 114 121 None => Ok(None), 115 122 } ··· 118 125 async fn get_token_by_refresh_token( 119 126 &self, 120 127 refresh_token: &RefreshToken, 121 - ) -> Result<Option<(i32, TokenData)>, DbError> { 128 + ) -> Result<Option<(TokenFamilyId, TokenData)>, DbError> { 122 129 let row = sqlx::query!( 123 130 r#" 124 131 SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, ··· 133 140 .map_err(map_sqlx_error)?; 134 141 match row { 135 142 Some(r) => Ok(Some(( 136 - r.id, 143 + TokenFamilyId::new(r.id), 137 144 TokenData { 138 - did: r.did.parse().map_err(|_| DbError::Other("Invalid DID in token".into()))?, 145 + did: r 146 + .did 147 + .parse() 148 + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, 139 149 token_id: OAuthTokenId(r.token_id), 140 150 created_at: r.created_at, 141 151 updated_at: r.updated_at, ··· 148 158 code: r.code.map(OAuthCode), 149 159 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 150 160 scope: r.scope, 151 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID".into()))?, 161 + controller_did: r 162 + .controller_did 163 + .map(|s| s.parse()) 164 + .transpose() 165 + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, 152 166 }, 153 167 ))), 154 168 None => Ok(None), ··· 158 172 async fn get_token_by_previous_refresh_token( 159 173 &self, 160 174 refresh_token: &RefreshToken, 161 - ) -> Result<Option<(i32, TokenData)>, DbError> { 175 + ) -> Result<Option<(TokenFamilyId, TokenData)>, DbError> { 162 176 let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS); 163 177 let row = sqlx::query!( 164 178 r#" ··· 175 189 .map_err(map_sqlx_error)?; 176 190 match row { 177 191 Some(r) => Ok(Some(( 178 - r.id, 192 + TokenFamilyId::new(r.id), 179 193 TokenData { 180 - did: r.did.parse().map_err(|_| DbError::Other("Invalid DID in token".into()))?, 194 + did: r 195 + .did 196 + .parse() 197 + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, 181 198 token_id: OAuthTokenId(r.token_id), 182 199 created_at: r.created_at, 183 200 updated_at: r.updated_at, ··· 190 207 code: r.code.map(OAuthCode), 191 208 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 192 209 scope: r.scope, 193 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID".into()))?, 210 + controller_did: r 211 + .controller_did 212 + .map(|s| s.parse()) 213 + .transpose() 214 + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, 194 215 }, 195 216 ))), 196 217 None => Ok(None), ··· 199 220 200 221 async fn rotate_token( 201 222 &self, 202 - old_db_id: i32, 223 + old_db_id: TokenFamilyId, 203 224 new_refresh_token: &RefreshToken, 204 225 new_expires_at: DateTime<Utc>, 205 226 ) -> Result<(), DbError> { ··· 208 229 r#" 209 230 SELECT current_refresh_token FROM oauth_token WHERE id = $1 210 231 "#, 211 - old_db_id 232 + old_db_id.as_i32() 212 233 ) 213 234 .fetch_one(&mut *tx) 214 235 .await ··· 220 241 VALUES ($1, $2) 221 242 "#, 222 243 old_rt, 223 - old_db_id 244 + old_db_id.as_i32() 224 245 ) 225 246 .execute(&mut *tx) 226 247 .await ··· 233 254 previous_refresh_token = $4, rotated_at = NOW() 234 255 WHERE id = $1 235 256 "#, 236 - old_db_id, 257 + old_db_id.as_i32(), 237 258 new_refresh_token.as_str(), 238 259 new_expires_at, 239 260 old_refresh ··· 248 269 async fn check_refresh_token_used( 249 270 &self, 250 271 refresh_token: &RefreshToken, 251 - ) -> Result<Option<i32>, DbError> { 272 + ) -> Result<Option<TokenFamilyId>, DbError> { 252 273 let row = sqlx::query_scalar!( 253 274 r#" 254 275 SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 ··· 258 279 .fetch_optional(&self.pool) 259 280 .await 260 281 .map_err(map_sqlx_error)?; 261 - Ok(row) 282 + Ok(row.map(TokenFamilyId::new)) 262 283 } 263 284 264 285 async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError> { ··· 274 295 Ok(()) 275 296 } 276 297 277 - async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError> { 298 + async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError> { 278 299 sqlx::query!( 279 300 r#" 280 301 DELETE FROM oauth_token WHERE id = $1 281 302 "#, 282 - db_id 303 + db_id.as_i32() 283 304 ) 284 305 .execute(&self.pool) 285 306 .await ··· 303 324 rows.into_iter() 304 325 .map(|r| { 305 326 Ok(TokenData { 306 - did: r.did.parse().map_err(|_| DbError::Other("Invalid DID in token".into()))?, 327 + did: r 328 + .did 329 + .parse() 330 + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, 307 331 token_id: OAuthTokenId(r.token_id), 308 332 created_at: r.created_at, 309 333 updated_at: r.updated_at, ··· 316 340 code: r.code.map(OAuthCode), 317 341 current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), 318 342 scope: r.scope, 319 - controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid controller DID".into()))?, 343 + controller_did: r 344 + .controller_did 345 + .map(|s| s.parse()) 346 + .transpose() 347 + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, 320 348 }) 321 349 }) 322 350 .collect() ··· 449 477 client_auth, 450 478 parameters, 451 479 expires_at: r.expires_at, 452 - did: r.did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid DID in DB".into()))?, 480 + did: r 481 + .did 482 + .map(|s| s.parse()) 483 + .transpose() 484 + .map_err(|_| DbError::Other("Invalid DID in DB".into()))?, 453 485 device_id: r.device_id.map(OAuthDeviceId), 454 486 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()))?, 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()))?, 456 492 })) 457 493 } 458 494 None => Ok(None), ··· 535 571 client_auth, 536 572 parameters, 537 573 expires_at: r.expires_at, 538 - did: r.did.map(|s| s.parse()).transpose().map_err(|_| DbError::Other("Invalid DID in DB".into()))?, 574 + did: r 575 + .did 576 + .map(|s| s.parse()) 577 + .transpose() 578 + .map_err(|_| DbError::Other("Invalid DID in DB".into()))?, 539 579 device_id: r.device_id.map(OAuthDeviceId), 540 580 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()))?, 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()))?, 542 586 })) 543 587 } 544 588 None => Ok(None), ··· 1208 1252 Ok(rows 1209 1253 .into_iter() 1210 1254 .map(|r| OAuthSessionListItem { 1211 - id: r.id, 1255 + id: TokenFamilyId::new(r.id), 1212 1256 token_id: TokenId::from(r.token_id), 1213 1257 created_at: r.created_at, 1214 1258 expires_at: r.expires_at, ··· 1217 1261 .collect()) 1218 1262 } 1219 1263 1220 - async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result<u64, DbError> { 1264 + async fn delete_session_by_id(&self, session_id: TokenFamilyId, did: &Did) -> Result<u64, DbError> { 1221 1265 let result = sqlx::query!( 1222 1266 "DELETE FROM oauth_token WHERE id = $1 AND did = $2", 1223 - session_id, 1267 + session_id.as_i32(), 1224 1268 did.as_str() 1225 1269 ) 1226 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 317 pub keys: Vec<JwkPublicKey>, 318 318 } 319 319 320 - 321 320 #[derive(Debug, Clone)] 322 321 pub struct FlowPending { 323 322 pub parameters: AuthorizationRequestParameters, ··· 460 459 } 461 460 } 462 461 463 - 464 462 #[derive(Debug, Clone, PartialEq, Eq)] 465 463 pub enum RefreshTokenState { 466 464 Valid, ··· 584 582 fn test_auth_flow_authorized() { 585 583 let did = test_did("did:plc:test"); 586 584 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 - ); 585 + let data = make_request_data(Some(did.clone()), Some(code.clone()), Duration::minutes(5)); 592 586 let flow = AuthFlow::from_request_data(data).expect("should not be expired"); 593 587 assert!(matches!(flow, AuthFlow::Authorized(_))); 594 588 let with_user = flow.clone().require_user().expect("should have user");
+6 -2
crates/tranquil-pds/src/api/admin/account/delete.rs
··· 36 36 .await 37 37 .log_db_err("deleting account")?; 38 38 39 - if let Err(e) = 40 - crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await 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 41 45 { 42 46 warn!( 43 47 "Failed to sequence account deletion event for {}: {}",
+1 -1
crates/tranquil-pds/src/api/admin/account/email.rs
··· 1 1 use crate::api::error::{ApiError, AtpJson, DbResultExt}; 2 2 use crate::auth::{Admin, Auth}; 3 3 use crate::state::AppState; 4 - use crate::util::pds_hostname; 5 4 use crate::types::Did; 5 + use crate::util::pds_hostname; 6 6 use axum::{ 7 7 Json, 8 8 extract::State,
+3 -3
crates/tranquil-pds/src/api/admin/account/info.rs
··· 149 149 .map(|ic| InviteCodeInfo { 150 150 code: ic.code.clone(), 151 151 available: ic.available_uses, 152 - disabled: ic.disabled, 152 + disabled: ic.state.is_disabled(), 153 153 for_account: ic.for_account, 154 154 created_by: ic.created_by, 155 155 created_at: ic.created_at.to_rfc3339(), ··· 177 177 Some(InviteCodeInfo { 178 178 code: info.code, 179 179 available: info.available_uses, 180 - disabled: info.disabled, 180 + disabled: info.state.is_disabled(), 181 181 for_account: info.for_account, 182 182 created_by: info.created_by, 183 183 created_at: info.created_at.to_rfc3339(), ··· 265 265 let info = InviteCodeInfo { 266 266 code: ic.code.clone(), 267 267 available: ic.available_uses, 268 - disabled: ic.disabled, 268 + disabled: ic.state.is_disabled(), 269 269 for_account: ic.for_account, 270 270 created_by: ic.created_by, 271 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 2 use crate::api::error::ApiError; 3 3 use crate::auth::{Admin, Auth}; 4 4 use crate::state::AppState; 5 - use crate::util::pds_hostname_without_port; 6 5 use crate::types::{Did, Handle, PlainPassword}; 6 + use crate::util::pds_hostname_without_port; 7 7 use axum::{ 8 8 Json, 9 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 135 InviteCodeInfo { 136 136 code: r.code.clone(), 137 137 available: r.available_uses, 138 - disabled: r.disabled.unwrap_or(false), 138 + disabled: r.state().is_disabled(), 139 139 for_account: creator_did.clone(), 140 140 created_by: creator_did, 141 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 2 use crate::api::repo::record::utils::create_signed_commit; 3 3 use crate::auth::{Active, Auth}; 4 4 use crate::delegation::{ 5 - DelegationActionType, SCOPE_PRESETS, scopes, verify_can_add_controllers, 5 + DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers, 6 6 verify_can_be_controller, verify_can_control_accounts, 7 7 }; 8 8 use crate::rate_limit::{AccountCreationLimit, RateLimited}; ··· 61 61 .map(|c| ControllerInfo { 62 62 did: c.did, 63 63 handle: c.handle, 64 - granted_scopes: c.granted_scopes, 64 + granted_scopes: c.granted_scopes.into_string(), 65 65 granted_at: c.granted_at, 66 66 is_active: c.is_active, 67 67 }) ··· 73 73 #[derive(Debug, Deserialize)] 74 74 pub struct AddControllerInput { 75 75 pub controller_did: Did, 76 - pub granted_scopes: String, 76 + pub granted_scopes: ValidatedDelegationScope, 77 77 } 78 78 79 79 pub async fn add_controller( ··· 81 81 auth: Auth<Active>, 82 82 Json(input): Json<AddControllerInput>, 83 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 84 let controller_exists = state 89 85 .user_repo 90 86 .get_by_did(&input.controller_did) ··· 97 93 return Ok(ApiError::ControllerNotFound.into_response()); 98 94 } 99 95 100 - let _can_add = match verify_can_add_controllers(&state, &auth).await { 96 + let can_add = match verify_can_add_controllers(&state, &auth).await { 101 97 Ok(proof) => proof, 102 98 Err(response) => return Ok(response), 103 99 }; 104 100 105 - if let Err(response) = verify_can_be_controller(&state, &input.controller_did).await { 106 - return Ok(response); 107 - } 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 + }; 108 105 109 106 match state 110 107 .delegation_repo 111 108 .create_delegation( 112 - &auth.did, 113 - &input.controller_did, 109 + can_add.did(), 110 + can_be_controller.did(), 114 111 &input.granted_scopes, 115 - &auth.did, 112 + can_add.did(), 116 113 ) 117 114 .await 118 115 { ··· 120 117 let _ = state 121 118 .delegation_repo 122 119 .log_delegation_action( 123 - &auth.did, 124 - &auth.did, 125 - Some(&input.controller_did), 120 + can_add.did(), 121 + can_add.did(), 122 + Some(can_be_controller.did()), 126 123 DelegationActionType::GrantCreated, 127 124 Some(serde_json::json!({ 128 - "granted_scopes": input.granted_scopes 125 + "granted_scopes": input.granted_scopes.as_str() 129 126 })), 130 127 None, 131 128 None, ··· 210 207 #[derive(Debug, Deserialize)] 211 208 pub struct UpdateControllerScopesInput { 212 209 pub controller_did: Did, 213 - pub granted_scopes: String, 210 + pub granted_scopes: ValidatedDelegationScope, 214 211 } 215 212 216 213 pub async fn update_controller_scopes( ··· 218 215 auth: Auth<Active>, 219 216 Json(input): Json<UpdateControllerScopesInput>, 220 217 ) -> 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 218 match state 226 219 .delegation_repo 227 - .update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes) 220 + .update_delegation_scopes( 221 + &auth.did, 222 + &input.controller_did, 223 + &input.granted_scopes, 224 + ) 228 225 .await 229 226 { 230 227 Ok(true) => { ··· 236 233 Some(&input.controller_did), 237 234 DelegationActionType::ScopesModified, 238 235 Some(serde_json::json!({ 239 - "new_scopes": input.granted_scopes 236 + "new_scopes": input.granted_scopes.as_str() 240 237 })), 241 238 None, 242 239 None, ··· 301 298 .map(|a| DelegatedAccountInfo { 302 299 did: a.did, 303 300 handle: a.handle, 304 - granted_scopes: a.granted_scopes, 301 + granted_scopes: a.granted_scopes.into_string(), 305 302 granted_at: a.granted_at, 306 303 }) 307 304 .collect(), ··· 418 415 pub struct CreateDelegatedAccountInput { 419 416 pub handle: String, 420 417 pub email: Option<String>, 421 - pub controller_scopes: String, 418 + pub controller_scopes: ValidatedDelegationScope, 422 419 pub invite_code: Option<String>, 423 420 } 424 421 ··· 435 432 auth: Auth<Active>, 436 433 Json(input): Json<CreateDelegatedAccountInput>, 437 434 ) -> 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 { 435 + let can_control = match verify_can_control_accounts(&state, &auth).await { 443 436 Ok(proof) => proof, 444 437 Err(response) => return Ok(response), 445 438 }; ··· 478 471 return Ok(ApiError::InvalidEmail.into_response()); 479 472 } 480 473 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()); 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()), 490 478 } 491 479 } else { 492 480 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 495 483 if invite_required { 496 484 return Ok(ApiError::InviteCodeRequired.into_response()); 497 485 } 498 - } 486 + None 487 + }; 499 488 500 489 use k256::ecdsa::SigningKey; 501 490 use rand::rngs::OsRng; ··· 546 535 547 536 let did = Did::new_unchecked(&genesis_result.did); 548 537 let handle = Handle::new_unchecked(&handle); 549 - info!(did = %did, handle = %handle, controller = %&auth.did, "Created DID for delegated account"); 538 + info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account"); 550 539 551 540 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 552 541 Ok(bytes) => bytes, ··· 586 575 handle: handle.clone(), 587 576 email: email.clone(), 588 577 did: did.clone(), 589 - controller_did: auth.did.clone(), 590 - controller_scopes: input.controller_scopes.clone(), 578 + controller_did: can_control.did().clone(), 579 + controller_scopes: input.controller_scopes.as_str().to_string(), 591 580 encrypted_key_bytes, 592 581 encryption_version: crate::config::ENCRYPTION_VERSION, 593 582 commit_cid: commit_cid.to_string(), ··· 596 585 invite_code: input.invite_code.clone(), 597 586 }; 598 587 599 - let _user_id = match state 588 + let user_id = match state 600 589 .user_repo 601 590 .create_delegated_account(&create_input) 602 591 .await ··· 614 603 } 615 604 }; 616 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 + 617 612 if let Err(e) = 618 613 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 619 614 { 620 615 warn!("Failed to sequence identity event for {}: {}", did, e); 621 616 } 622 - if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 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 623 { 624 624 warn!("Failed to sequence account event for {}: {}", did, e); 625 625 } ··· 651 651 DelegationActionType::GrantCreated, 652 652 Some(json!({ 653 653 "account_created": true, 654 - "granted_scopes": input.controller_scopes 654 + "granted_scopes": input.controller_scopes.as_str() 655 655 })), 656 656 None, 657 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 298 d.clone() 299 299 } else if d.starts_with("did:web:") { 300 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 301 + && let Err(e) = 302 + verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()) 303 + .await 308 304 { 309 305 return ApiError::InvalidDid(e).into_response(); 310 306 } ··· 444 440 refresh_jti: refresh_meta.jti.clone(), 445 441 access_expires_at: access_meta.expires_at, 446 442 refresh_expires_at: refresh_meta.expires_at, 447 - legacy_login: false, 443 + login_type: tranquil_db_traits::LoginType::Modern, 448 444 mfa_verified: false, 449 445 scope: None, 450 446 controller_did: None, ··· 687 683 { 688 684 warn!("Failed to sequence identity event for {}: {}", did, e); 689 685 } 690 - if let Err(e) = 691 - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await 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 692 { 693 693 warn!("Failed to sequence account event for {}: {}", did, e); 694 694 } ··· 796 796 refresh_jti: refresh_meta.jti.clone(), 797 797 access_expires_at: access_meta.expires_at, 798 798 refresh_expires_at: refresh_meta.expires_at, 799 - legacy_login: false, 799 + login_type: tranquil_db_traits::LoginType::Modern, 800 800 mfa_verified: false, 801 801 scope: None, 802 802 controller_did: None,
+3 -1
crates/tranquil-pds/src/api/identity/did.rs
··· 1 1 use crate::api::{ApiError, DidResponse, EmptyResponse}; 2 2 use crate::auth::{Auth, NotTakendown}; 3 3 use crate::plc::signing_key_to_did_key; 4 - use crate::rate_limit::{HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message}; 4 + use crate::rate_limit::{ 5 + HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message, 6 + }; 5 7 use crate::state::AppState; 6 8 use crate::types::Handle; 7 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 1 use crate::api::ApiError; 2 + use crate::api::error::DbResultExt; 3 3 use crate::auth::{Auth, Permissive}; 4 4 use crate::circuit_breaker::with_circuit_breaker; 5 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 10 use serde::{Deserialize, Serialize}; 11 11 use serde_json::json; 12 12 use tracing::info; 13 + use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType}; 13 14 14 15 #[derive(Serialize)] 15 16 #[serde(rename_all = "camelCase")] 16 17 pub struct NotificationPrefsResponse { 17 - pub preferred_channel: String, 18 + pub preferred_channel: CommsChannel, 18 19 pub email: String, 19 20 pub discord_id: Option<String>, 20 21 pub discord_verified: bool, ··· 51 52 #[serde(rename_all = "camelCase")] 52 53 pub struct NotificationHistoryEntry { 53 54 pub created_at: String, 54 - pub channel: String, 55 - pub comms_type: String, 56 - pub status: String, 55 + pub channel: CommsChannel, 56 + pub comms_type: CommsType, 57 + pub status: CommsStatus, 57 58 pub subject: Option<String>, 58 59 pub body: String, 59 60 } ··· 82 83 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 83 84 84 85 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", 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 94 ]; 95 95 96 96 let notifications = rows 97 97 .iter() 98 98 .map(|row| { 99 - let body = if sensitive_types.contains(&row.comms_type.as_str()) { 99 + let body = if sensitive_types.contains(&row.comms_type) { 100 100 "[Code redacted for security]".to_string() 101 101 } else { 102 102 row.body.clone() 103 103 }; 104 104 NotificationHistoryEntry { 105 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(), 106 + channel: row.channel, 107 + comms_type: row.comms_type, 108 + status: row.status, 109 109 subject: row.subject.clone(), 110 110 body, 111 111 } ··· 201 201 202 202 let mut verification_required: Vec<String> = Vec::new(); 203 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 - } 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 + }; 211 216 state 212 217 .user_repo 213 218 .update_preferred_comms_channel(&auth.did, channel) 214 219 .await 215 220 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 216 - info!(did = %auth.did, channel = %channel, "Updated preferred notification channel"); 221 + info!(did = %auth.did, channel = ?channel, "Updated preferred notification channel"); 217 222 } 218 223 219 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 58 } 59 59 let mime_type_for_check = 60 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) { 61 + let scope_proof = match user.verify_blob_upload(mime_type_for_check) { 62 62 Ok(proof) => proof, 63 63 Err(e) => return Ok(e.into_response()), 64 64 }; 65 - (user.did.clone(), user.controller_did.clone()) 65 + ( 66 + scope_proof.principal_did().into_did(), 67 + scope_proof.controller_did().map(|c| c.into_did()), 68 + ) 66 69 } 67 70 }; 68 71
+1 -1
crates/tranquil-pds/src/api/repo/import.rs
··· 365 365 ) -> Result<(), tranquil_db::DbError> { 366 366 let data = tranquil_db::CommitEventData { 367 367 did: did.clone(), 368 - event_type: "commit".to_string(), 368 + event_type: tranquil_db::RepoEventType::Commit, 369 369 commit_cid: Some(CidLink::new_unchecked(commit_cid)), 370 370 prev_cid: None, 371 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 1 use super::validation::validate_record_with_status; 2 + use super::validation_mode::{ValidationMode, deserialize_validation_mode}; 2 3 use crate::api::error::ApiError; 3 4 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 4 - use crate::auth::{Active, Auth, VerifyScope}; 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; 5 10 use crate::delegation::DelegationActionType; 6 11 use crate::repo::tracking::TrackingBlockStore; 7 12 use crate::state::AppState; ··· 34 39 write: &WriteOp, 35 40 acc: WriteAccumulator, 36 41 did: &Did, 37 - validate: Option<bool>, 42 + validate: ValidationMode, 38 43 tracking_store: &TrackingBlockStore, 39 44 ) -> Result<WriteAccumulator, Response> { 40 45 let WriteAccumulator { ··· 51 56 rkey, 52 57 value, 53 58 } => { 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 - } 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), 67 70 } 68 71 }; 69 72 all_blob_cids.extend(extract_blob_cids(value)); ··· 104 107 rkey, 105 108 value, 106 109 } => { 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 - } 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), 120 121 } 121 122 }; 122 123 all_blob_cids.extend(extract_blob_cids(value)); ··· 181 182 writes: &[WriteOp], 182 183 initial_mst: Mst<TrackingBlockStore>, 183 184 did: &Did, 184 - validate: Option<bool>, 185 + validate: ValidationMode, 185 186 tracking_store: &TrackingBlockStore, 186 187 ) -> Result<WriteAccumulator, Response> { 187 188 use futures::stream::{self, TryStreamExt}; ··· 222 223 #[serde(rename_all = "camelCase")] 223 224 pub struct ApplyWritesInput { 224 225 pub repo: AtIdentifier, 225 - pub validate: Option<bool>, 226 + #[serde(default, deserialize_with = "deserialize_validation_mode")] 227 + pub validate: ValidationMode, 226 228 pub writes: Vec<WriteOp>, 227 229 pub swap_commit: Option<String>, 228 230 } ··· 270 272 input.repo, 271 273 input.writes.len() 272 274 ); 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 - } 275 + 301 276 if input.writes.is_empty() { 302 277 return Err(ApiError::InvalidRequest("writes array is empty".into())); 303 278 } ··· 308 283 ))); 309 284 } 310 285 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(); 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 + }; 346 304 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 - } 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 + )); 362 312 } 363 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 + 364 322 let user_id: uuid::Uuid = state 365 323 366 324 ··· 375 333 .ok() 376 334 .flatten() 377 335 .ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?; 378 - let current_root_cid = Cid::from_str(&root_cid_str) 336 + let current_root_cid = CommitCid::from_str(&root_cid_str) 379 337 .map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?; 380 338 if let Some(swap_commit) = &input.swap_commit 381 - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) 339 + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) 382 340 { 383 341 return Err(ApiError::InvalidSwap(Some("Repo has been modified".into()))); 384 342 } 385 343 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 386 344 let commit_bytes = tracking_store 387 - .get(&current_root_cid) 345 + .get(current_root_cid.as_cid()) 388 346 .await 389 347 .ok() 390 348 .flatten() ··· 452 410 } => Some(*cid), 453 411 _ => None, 454 412 }); 455 - let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid) 413 + let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid()) 456 414 .chain( 457 415 old_mst_blocks 458 416 .keys() ··· 468 426 CommitParams { 469 427 did: &did, 470 428 user_id, 471 - current_root_cid: Some(current_root_cid), 429 + current_root_cid: Some(current_root_cid.into_cid()), 472 430 prev_data_cid: Some(commit.data), 473 431 new_mst_root, 474 432 ops,
+7 -6
crates/tranquil-pds/src/api/repo/record/delete.rs
··· 2 2 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 3 3 use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; 4 4 use crate::auth::{Active, Auth, VerifyScope}; 5 + use crate::cid_types::CommitCid; 5 6 use crate::delegation::DelegationActionType; 6 7 use crate::repo::tracking::TrackingBlockStore; 7 8 use crate::state::AppState; ··· 43 44 auth: Auth<Active>, 44 45 Json(input): Json<DeleteRecordInput>, 45 46 ) -> Result<Response, crate::api::error::ApiError> { 46 - let _scope_proof = match auth.verify_repo_delete(&input.collection) { 47 + let scope_proof = match auth.verify_repo_delete(&input.collection) { 47 48 Ok(proof) => proof, 48 49 Err(e) => return Ok(e.into_response()), 49 50 }; 50 51 51 - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { 52 + let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await { 52 53 Ok(res) => res, 53 54 Err(err_res) => return Ok(err_res), 54 55 }; ··· 59 60 let controller_did = repo_auth.controller_did; 60 61 61 62 if let Some(swap_commit) = &input.swap_commit 62 - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) 63 + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) 63 64 { 64 65 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 65 66 } 66 67 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 67 - let commit_bytes = match tracking_store.get(&current_root_cid).await { 68 + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { 68 69 Ok(Some(b)) => b, 69 70 _ => { 70 71 return Ok( ··· 155 156 .into_iter() 156 157 .collect(); 157 158 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 + let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid()) 159 160 .chain( 160 161 old_mst_blocks 161 162 .keys() ··· 169 170 CommitParams { 170 171 did: &did, 171 172 user_id, 172 - current_root_cid: Some(current_root_cid), 173 + current_root_cid: Some(current_root_cid.into_cid()), 173 174 prev_data_cid: Some(commit.data), 174 175 new_mst_root, 175 176 ops: vec![op],
+4 -3
crates/tranquil-pds/src/api/repo/record/read.rs
··· 1 + use super::pagination::{PaginationDirection, deserialize_pagination_direction}; 1 2 use crate::api::error::ApiError; 2 3 use crate::state::AppState; 3 4 use crate::types::{AtIdentifier, Nsid, Rkey}; ··· 144 145 pub rkey_start: Option<Rkey>, 145 146 #[serde(rename = "rkeyEnd")] 146 147 pub rkey_end: Option<Rkey>, 147 - pub reverse: Option<bool>, 148 + #[serde(default, deserialize_with = "deserialize_pagination_direction")] 149 + pub reverse: PaginationDirection, 148 150 } 149 151 #[derive(Serialize)] 150 152 pub struct ListRecordsOutput { ··· 193 195 } 194 196 }; 195 197 let limit = input.limit.unwrap_or(50).clamp(1, 100); 196 - let reverse = input.reverse.unwrap_or(false); 197 198 let limit_i64 = limit as i64; 198 199 let cursor_rkey = input 199 200 .cursor ··· 206 207 &input.collection, 207 208 cursor_rkey.as_ref(), 208 209 limit_i64, 209 - reverse, 210 + input.reverse.is_reverse(), 210 211 input.rkey_start.as_ref(), 211 212 input.rkey_end.as_ref(), 212 213 )
+43 -57
crates/tranquil-pds/src/api/repo/record/write.rs
··· 1 1 use super::validation::validate_record_with_status; 2 + use super::validation_mode::{ValidationMode, deserialize_validation_mode}; 2 3 use crate::api::error::ApiError; 3 4 use crate::api::repo::record::utils::{ 4 5 CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids, 5 6 }; 6 - use crate::auth::{Active, Auth, VerifyScope}; 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; 7 12 use crate::delegation::DelegationActionType; 8 13 use crate::repo::tracking::TrackingBlockStore; 9 14 use crate::state::AppState; ··· 26 31 pub struct RepoWriteAuth { 27 32 pub did: Did, 28 33 pub user_id: Uuid, 29 - pub current_root_cid: Cid, 34 + pub current_root_cid: CommitCid, 30 35 pub is_oauth: bool, 31 36 pub scope: Option<String>, 32 37 pub controller_did: Option<Did>, 33 38 } 34 39 35 - pub async fn prepare_repo_write( 40 + pub async fn prepare_repo_write<A: RepoScopeAction>( 36 41 state: &AppState, 37 - auth_user: &crate::auth::AuthenticatedUser, 42 + scope_proof: &ScopeVerified<'_, A>, 38 43 repo: &AtIdentifier, 39 44 ) -> Result<RepoWriteAuth, Response> { 40 - if repo.as_str() != auth_user.did.as_str() { 45 + let user = scope_proof.user(); 46 + let principal_did = scope_proof.principal_did(); 47 + if repo.as_str() != principal_did.as_str() { 41 48 return Err( 42 49 ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(), 43 50 ); 44 51 } 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 - } 52 + 53 + require_not_migrated(state, principal_did.as_did()).await?; 54 + let _account_verified = require_verified_or_delegated(state, user).await?; 55 + 66 56 let user_id = state 67 57 .user_repo 68 - .get_id_by_did(&auth_user.did) 58 + .get_id_by_did(principal_did.as_did()) 69 59 .await 70 60 .map_err(|e| { 71 61 error!("DB error fetching user: {}", e); ··· 83 73 .ok_or_else(|| { 84 74 ApiError::InternalError(Some("Repo root not found".into())).into_response() 85 75 })?; 86 - let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| { 76 + let current_root_cid = CommitCid::from_str(&root_cid_str).map_err(|_| { 87 77 ApiError::InternalError(Some("Invalid repo root CID".into())).into_response() 88 78 })?; 89 79 Ok(RepoWriteAuth { 90 - did: auth_user.did.clone(), 80 + did: principal_did.into_did(), 91 81 user_id, 92 82 current_root_cid, 93 - is_oauth: auth_user.is_oauth(), 94 - scope: auth_user.scope.clone(), 95 - controller_did: auth_user.controller_did.clone(), 83 + is_oauth: user.is_oauth(), 84 + scope: user.scope.clone(), 85 + controller_did: scope_proof.controller_did().map(|c| c.into_did()), 96 86 }) 97 87 } 98 88 #[derive(Deserialize)] ··· 101 91 pub repo: AtIdentifier, 102 92 pub collection: Nsid, 103 93 pub rkey: Option<Rkey>, 104 - pub validate: Option<bool>, 94 + #[serde(default, deserialize_with = "deserialize_validation_mode")] 95 + pub validate: ValidationMode, 105 96 pub record: serde_json::Value, 106 97 #[serde(rename = "swapCommit")] 107 98 pub swap_commit: Option<String>, ··· 127 118 auth: Auth<Active>, 128 119 Json(input): Json<CreateRecordInput>, 129 120 ) -> Result<Response, crate::api::error::ApiError> { 130 - let _scope_proof = match auth.verify_repo_create(&input.collection) { 121 + let scope_proof = match auth.verify_repo_create(&input.collection) { 131 122 Ok(proof) => proof, 132 123 Err(e) => return Ok(e.into_response()), 133 124 }; 134 125 135 - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { 126 + let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await { 136 127 Ok(res) => res, 137 128 Err(err_res) => return Ok(err_res), 138 129 }; ··· 143 134 let controller_did = repo_auth.controller_did; 144 135 145 136 if let Some(swap_commit) = &input.swap_commit 146 - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) 137 + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) 147 138 { 148 139 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 149 140 } 150 141 151 - let validation_status = if input.validate == Some(false) { 142 + let validation_status = if input.validate.should_skip() { 152 143 None 153 144 } else { 154 - let require_lexicon = input.validate == Some(true); 155 145 match validate_record_with_status( 156 146 &input.record, 157 147 &input.collection, 158 148 input.rkey.as_ref(), 159 - require_lexicon, 149 + input.validate.requires_lexicon(), 160 150 ) { 161 151 Ok(status) => Some(status), 162 152 Err(err_response) => return Ok(*err_response), ··· 165 155 let rkey = input.rkey.unwrap_or_else(Rkey::generate); 166 156 167 157 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 168 - let commit_bytes = match tracking_store.get(&current_root_cid).await { 158 + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { 169 159 Ok(Some(b)) => b, 170 160 _ => { 171 161 return Ok( ··· 188 178 let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new(); 189 179 let mut all_old_mst_blocks = std::collections::BTreeMap::new(); 190 180 191 - if input.validate != Some(false) { 181 + if !input.validate.should_skip() { 192 182 let record_uri = AtUri::from_parts(&did, &input.collection, &rkey); 193 183 let backlinks = extract_backlinks(&record_uri, &input.record); 194 184 ··· 319 309 .collect(); 320 310 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 321 311 let blob_cids = extract_blob_cids(&input.record); 322 - let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid) 312 + let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid()) 323 313 .chain( 324 314 all_old_mst_blocks 325 315 .keys() ··· 333 323 CommitParams { 334 324 did: &did, 335 325 user_id, 336 - current_root_cid: Some(current_root_cid), 326 + current_root_cid: Some(current_root_cid.into_cid()), 337 327 prev_data_cid: Some(initial_mst_root), 338 328 new_mst_root, 339 329 ops, ··· 408 398 pub repo: AtIdentifier, 409 399 pub collection: Nsid, 410 400 pub rkey: Rkey, 411 - pub validate: Option<bool>, 401 + #[serde(default, deserialize_with = "deserialize_validation_mode")] 402 + pub validate: ValidationMode, 412 403 pub record: serde_json::Value, 413 404 #[serde(rename = "swapCommit")] 414 405 pub swap_commit: Option<String>, ··· 430 421 auth: Auth<Active>, 431 422 Json(input): Json<PutRecordInput>, 432 423 ) -> Result<Response, crate::api::error::ApiError> { 433 - let _create_proof = match auth.verify_repo_create(&input.collection) { 424 + let upsert_proof = match auth.verify_repo_upsert(&input.collection) { 434 425 Ok(proof) => proof, 435 426 Err(e) => return Ok(e.into_response()), 436 427 }; 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 428 442 - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { 429 + let repo_auth = match prepare_repo_write(&state, &upsert_proof, &input.repo).await { 443 430 Ok(res) => res, 444 431 Err(err_res) => return Ok(err_res), 445 432 }; ··· 450 437 let controller_did = repo_auth.controller_did; 451 438 452 439 if let Some(swap_commit) = &input.swap_commit 453 - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) 440 + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) 454 441 { 455 442 return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); 456 443 } 457 444 let tracking_store = TrackingBlockStore::new(state.block_store.clone()); 458 - let commit_bytes = match tracking_store.get(&current_root_cid).await { 445 + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { 459 446 Ok(Some(b)) => b, 460 447 _ => { 461 448 return Ok( ··· 473 460 }; 474 461 let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); 475 462 let key = format!("{}/{}", input.collection, input.rkey); 476 - let validation_status = if input.validate == Some(false) { 463 + let validation_status = if input.validate.should_skip() { 477 464 None 478 465 } else { 479 - let require_lexicon = input.validate == Some(true); 480 466 match validate_record_with_status( 481 467 &input.record, 482 468 &input.collection, 483 469 Some(&input.rkey), 484 - require_lexicon, 470 + input.validate.requires_lexicon(), 485 471 ) { 486 472 Ok(status) => Some(status), 487 473 Err(err_response) => return Ok(*err_response), ··· 598 584 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 599 585 let is_update = existing_cid.is_some(); 600 586 let blob_cids = extract_blob_cids(&input.record); 601 - let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid) 587 + let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid()) 602 588 .chain( 603 589 old_mst_blocks 604 590 .keys() ··· 612 598 CommitParams { 613 599 did: &did, 614 600 user_id, 615 - current_root_cid: Some(current_root_cid), 601 + current_root_cid: Some(current_root_cid.into_cid()), 616 602 prev_data_cid: Some(commit.data), 617 603 new_mst_root, 618 604 ops: vec![op],
+13 -6
crates/tranquil-pds/src/api/server/account_status.rs
··· 380 380 "[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}", 381 381 did 382 382 ); 383 - if let Err(e) = 384 - crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 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 385 389 { 386 390 warn!( 387 391 "[MIGRATION] activateAccount: Failed to sequence account activation event: {}", ··· 503 507 if let Err(e) = crate::api::repo::record::sequence_account_event( 504 508 &state, 505 509 &did, 506 - false, 507 - Some("deactivated"), 510 + tranquil_db_traits::AccountStatus::Deactivated, 508 511 ) 509 512 .await 510 513 { ··· 634 637 error!("DB error deleting account: {:?}", e); 635 638 return ApiError::InternalError(None).into_response(); 636 639 } 637 - let account_seq = 638 - crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await; 640 + let account_seq = crate::api::repo::record::sequence_account_event( 641 + &state, 642 + did, 643 + tranquil_db_traits::AccountStatus::Deleted, 644 + ) 645 + .await; 639 646 match account_seq { 640 647 Ok(seq) => { 641 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 52 .map(|row| AppPassword { 53 53 name: row.name.clone(), 54 54 created_at: row.created_at.to_rfc3339(), 55 - privileged: row.privileged, 55 + privileged: row.privilege.is_privileged(), 56 56 scopes: row.scopes.clone(), 57 57 created_by_controller: row 58 58 .created_by_controller_did ··· 119 119 let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 120 120 121 121 let requested = input.scopes.as_deref().unwrap_or("atproto"); 122 - let intersected = intersect_scopes(requested, &granted_scopes); 122 + let intersected = intersect_scopes(requested, granted_scopes.as_str()); 123 123 124 124 if intersected.is_empty() && !granted_scopes.is_empty() { 125 125 return Err(ApiError::InsufficientScope(None)); ··· 150 150 ApiError::InternalError(None) 151 151 })?; 152 152 153 - let privileged = input.privileged.unwrap_or(false); 153 + let privilege = tranquil_db_traits::AppPasswordPrivilege::from(input.privileged.unwrap_or(false)); 154 154 let created_at = chrono::Utc::now(); 155 155 156 156 let create_data = AppPasswordCreate { 157 157 user_id: user.id, 158 158 name: name.to_string(), 159 159 password_hash, 160 - privileged, 160 + privilege, 161 161 scopes: final_scopes.clone(), 162 162 created_by_controller_did: controller_did.clone(), 163 163 }; ··· 190 190 name: name.to_string(), 191 191 password, 192 192 created_at: created_at.to_rfc3339(), 193 - privileged, 193 + privileged: privilege.is_privileged(), 194 194 scopes: final_scopes, 195 195 }) 196 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 1 use crate::api::ApiError; 2 + use crate::api::error::DbResultExt; 3 3 use crate::auth::{Admin, Auth, NotTakendown}; 4 4 use crate::state::AppState; 5 5 use crate::types::Did; ··· 205 205 206 206 let filtered_codes: Vec<_> = codes_info 207 207 .into_iter() 208 - .filter(|info| !info.disabled) 208 + .filter(|info| info.state.is_active()) 209 209 .collect(); 210 210 211 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 1 use crate::api::ApiError; 2 + use crate::api::error::DbResultExt; 3 3 use crate::auth::{Active, Auth}; 4 4 use crate::state::AppState; 5 5 use crate::util::pds_hostname;
+17 -19
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 1 1 use crate::api::SuccessResponse; 2 2 use crate::api::error::ApiError; 3 + use crate::auth::NormalizedLoginIdentifier; 3 4 use axum::{ 4 5 Json, 5 6 extract::State, ··· 145 146 return ApiError::InvalidEmail.into_response(); 146 147 } 147 148 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(); 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(), 157 153 } 158 154 } else { 159 155 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 162 158 if invite_required { 163 159 return ApiError::InviteCodeRequired.into_response(); 164 160 } 165 - } 161 + None 162 + }; 166 163 167 164 let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); 168 165 let verification_recipient = match verification_channel { ··· 460 457 { 461 458 warn!("Failed to sequence identity event for {}: {}", did, e); 462 459 } 463 - if let Err(e) = 464 - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await 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 465 466 { 466 467 warn!("Failed to sequence account event for {}: {}", did, e); 467 468 } ··· 517 518 refresh_jti, 518 519 access_expires_at: token_meta.expires_at, 519 520 refresh_expires_at: refresh_expires, 520 - legacy_login: false, 521 + login_type: tranquil_db::LoginType::Modern, 521 522 mfa_verified: false, 522 523 scope: None, 523 524 controller_did: None, ··· 808 809 let hostname_for_handles = pds_hostname_without_port(); 809 810 let identifier = input.email.trim().to_lowercase(); 810 811 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 - }; 812 + let normalized_handle = 813 + NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles); 816 814 817 815 let user = match state 818 816 .user_repo 819 - .get_user_for_passkey_recovery(identifier, &normalized_handle) 817 + .get_user_for_passkey_recovery(identifier, normalized_handle.as_str()) 820 818 .await 821 819 { 822 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 1 use crate::api::error::{ApiError, DbResultExt}; 2 2 use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse}; 3 3 use crate::auth::{ 4 - Active, Auth, require_legacy_session_mfa, require_reauth_window, 4 + Active, Auth, NormalizedLoginIdentifier, require_legacy_session_mfa, require_reauth_window, 5 5 require_reauth_window_if_available, 6 6 }; 7 7 use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit}; ··· 42 42 let normalized = identifier.to_lowercase(); 43 43 let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); 44 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 - }; 45 + let normalized_handle = NormalizedLoginIdentifier::normalize(identifier, hostname_for_handles); 50 46 51 47 let multiple_accounts_warning = if is_email_lookup { 52 48 match state.user_repo.count_accounts_by_email(normalized).await { ··· 59 55 60 56 let user_id = match state 61 57 .user_repo 62 - .get_id_by_email_or_handle(normalized, &normalized_handle) 58 + .get_id_by_email_or_handle(normalized, normalized_handle.as_str()) 63 59 .await 64 60 { 65 61 Ok(Some(id)) => id,
+1 -1
crates/tranquil-pds/src/api/server/reauth.rs
··· 381 381 ) -> bool { 382 382 match session_repo.get_session_mfa_status(did).await { 383 383 Ok(Some(status)) => { 384 - if !status.legacy_login { 384 + if status.login_type.is_modern() { 385 385 return true; 386 386 } 387 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 1 use crate::api::error::{ApiError, DbResultExt}; 2 2 use crate::api::{EmptyResponse, SuccessResponse}; 3 - use crate::auth::{Active, Auth, Permissive, require_legacy_session_mfa, require_reauth_window}; 3 + use crate::auth::{ 4 + Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa, 5 + require_reauth_window, 6 + }; 4 7 use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit}; 5 8 use crate::state::AppState; 6 9 use crate::types::{AccountState, Did, Handle, PlainPassword}; ··· 15 18 use serde::{Deserialize, Serialize}; 16 19 use serde_json::json; 17 20 use tracing::{error, info, warn}; 21 + use tranquil_db_traits::{SessionId, TokenFamilyId}; 18 22 use tranquil_types::TokenId; 19 23 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 24 fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { 32 25 stored_handle.to_string() 33 26 } ··· 72 65 ); 73 66 let pds_host = pds_hostname(); 74 67 let hostname_for_handles = pds_hostname_without_port(); 75 - let normalized_identifier = normalize_handle(&input.identifier, hostname_for_handles); 68 + let normalized_identifier = 69 + NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles); 76 70 info!( 77 71 "Normalized identifier: {} -> {}", 78 72 input.identifier, normalized_identifier 79 73 ); 80 74 let row = match state 81 75 .user_repo 82 - .get_login_full_by_identifier(&normalized_identifier) 76 + .get_login_full_by_identifier(normalized_identifier.as_str()) 83 77 .await 84 78 { 85 79 Ok(Some(row)) => row, ··· 145 139 warn!("Login attempt for takendown account: {}", row.did); 146 140 return ApiError::AccountTakedown.into_response(); 147 141 } 148 - let is_verified = 149 - row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 142 + let is_verified = row.channel_verification.has_any_verified(); 150 143 let is_delegated = state 151 144 .delegation_repo 152 145 .is_delegated_account(&row.did) ··· 206 199 refresh_jti: refresh_meta.jti.clone(), 207 200 access_expires_at: access_meta.expires_at, 208 201 refresh_expires_at: refresh_meta.expires_at, 209 - legacy_login: is_legacy_login, 202 + login_type: tranquil_db_traits::LoginType::from(is_legacy_login), 210 203 mfa_verified: false, 211 204 scope: app_password_scopes.clone(), 212 205 controller_did: app_password_controller.clone(), ··· 250 243 did: row.did, 251 244 did_doc, 252 245 email: row.email, 253 - email_confirmed: Some(row.email_verified), 246 + email_confirmed: Some(row.channel_verification.email), 254 247 active: Some(is_active), 255 248 status, 256 249 }) ··· 272 265 ); 273 266 match db_result { 274 267 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), 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", 281 273 }; 274 + let preferred_channel_verified = 275 + row.channel_verification.is_verified(row.preferred_comms_channel); 282 276 let pds_hostname = pds_hostname(); 283 277 let handle = full_handle(&row.handle, pds_hostname); 284 278 let account_state = AccountState::from_db_fields( ··· 292 286 } else { 293 287 None 294 288 }; 295 - let email_confirmed_value = can_read_email && row.email_verified; 289 + let email_confirmed_value = can_read_email && row.channel_verification.email; 296 290 let mut response = json!({ 297 291 "handle": handle, 298 292 "did": &auth.did, ··· 331 325 headers: axum::http::HeaderMap, 332 326 _auth: Auth<Active>, 333 327 ) -> Result<Response, ApiError> { 334 - let extracted = crate::auth::extract_auth_token_from_header( 335 - crate::util::get_header_str(&headers, "Authorization"), 336 - ) 328 + let extracted = crate::auth::extract_auth_token_from_header(crate::util::get_header_str( 329 + &headers, 330 + "Authorization", 331 + )) 337 332 .ok_or(ApiError::AuthenticationRequired)?; 338 333 let jti = crate::auth::get_jti_from_token(&extracted.token) 339 334 .map_err(|_| ApiError::AuthenticationFailed(None))?; ··· 356 351 _rate_limit: RateLimited<RefreshSessionLimit>, 357 352 headers: axum::http::HeaderMap, 358 353 ) -> Response { 359 - let extracted = match crate::auth::extract_auth_token_from_header( 360 - crate::util::get_header_str(&headers, "Authorization"), 361 - ) { 354 + let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str( 355 + &headers, 356 + "Authorization", 357 + )) { 362 358 Some(t) => t, 363 359 None => return ApiError::AuthenticationRequired.into_response(), 364 360 }; ··· 475 471 ); 476 472 match db_result { 477 473 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), 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", 483 479 }; 480 + let preferred_channel_verified = 481 + u.channel_verification.is_verified(u.preferred_comms_channel); 484 482 let pds_hostname = pds_hostname(); 485 483 let handle = full_handle(&u.handle, pds_hostname); 486 484 let account_state = ··· 491 489 "handle": handle, 492 490 "did": session_row.did, 493 491 "email": u.email, 494 - "emailConfirmed": u.email_verified, 492 + "emailConfirmed": u.channel_verification.email, 495 493 "preferredChannel": preferred_channel, 496 494 "preferredChannelVerified": preferred_channel_verified, 497 495 "preferredLocale": u.preferred_locale, ··· 635 633 refresh_jti: refresh_meta.jti.clone(), 636 634 access_expires_at: access_meta.expires_at, 637 635 refresh_expires_at: refresh_meta.expires_at, 638 - legacy_login: false, 636 + login_type: tranquil_db_traits::LoginType::Modern, 639 637 mfa_verified: false, 640 638 scope: None, 641 639 controller_did: None, ··· 702 700 return ApiError::InternalError(None).into_response(); 703 701 } 704 702 }; 705 - let is_verified = 706 - row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 703 + let is_verified = row.channel_verification.has_any_verified(); 707 704 if is_verified { 708 705 return ApiError::InvalidRequest("Account is already verified".into()).into_response(); 709 706 } ··· 834 831 Json(input): Json<RevokeSessionInput>, 835 832 ) -> Result<Response, ApiError> { 836 833 if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") { 837 - let session_id: i32 = jwt_id 838 - .parse() 834 + let session_id = jwt_id 835 + .parse::<i32>() 836 + .map(SessionId::new) 839 837 .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; 840 838 let access_jti = state 841 839 .session_repo ··· 854 852 } 855 853 info!(did = %&auth.did, session_id = %session_id, "JWT session revoked"); 856 854 } else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") { 857 - let session_id: i32 = oauth_id 858 - .parse() 855 + let session_id = oauth_id 856 + .parse::<i32>() 857 + .map(TokenFamilyId::new) 859 858 .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; 860 859 let deleted = state 861 860 .oauth_repo
+26 -21
crates/tranquil-pds/src/api/server/totp.rs
··· 32 32 State(state): State<AppState>, 33 33 auth: Auth<Active>, 34 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(_) => {} 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) => {} 38 40 Err(e) => { 39 41 error!("DB error checking TOTP: {:?}", e); 40 42 return Err(ApiError::InternalError(None)); ··· 97 99 auth: Auth<Active>, 98 100 Json(input): Json<EnableTotpInput>, 99 101 ) -> Result<Response, ApiError> { 102 + use tranquil_db_traits::TotpRecordState; 103 + 100 104 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 101 105 &state, 102 106 &auth.did, ··· 104 108 ) 105 109 .await?; 106 110 107 - let totp_record = match state.user_repo.get_totp_record(&auth.did).await { 108 - Ok(Some(row)) => row, 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), 109 114 Ok(None) => return Err(ApiError::TotpNotEnabled), 110 115 Err(e) => { 111 116 error!("DB error fetching TOTP: {:?}", e); ··· 113 118 } 114 119 }; 115 120 116 - if totp_record.verified { 117 - return Err(ApiError::TotpAlreadyEnabled); 118 - } 119 - 120 121 let secret = decrypt_totp_secret( 121 - &totp_record.secret_encrypted, 122 - totp_record.encryption_version, 122 + &unverified_record.secret_encrypted, 123 + unverified_record.encryption_version, 123 124 ) 124 125 .map_err(|e| { 125 126 error!("Failed to decrypt TOTP secret: {:?}", e); ··· 165 166 auth: Auth<Active>, 166 167 Json(input): Json<DisableTotpInput>, 167 168 ) -> Result<Response, ApiError> { 168 - let _session_mfa = match require_legacy_session_mfa(&state, &auth).await { 169 + let session_mfa = match require_legacy_session_mfa(&state, &auth).await { 169 170 Ok(proof) => proof, 170 171 Err(response) => return Ok(response), 171 172 }; 172 173 173 174 let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( 174 175 &state, 175 - &auth.did, 176 + session_mfa.did(), 176 177 "Too many verification attempts. Please try again in a few minutes.", 177 178 ) 178 179 .await?; ··· 186 187 .await 187 188 .log_db_err("deleting TOTP")?; 188 189 189 - info!(did = %password_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method()); 190 + info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method()); 190 191 191 192 Ok(EmptyResponse::ok().into_response()) 192 193 } ··· 203 204 State(state): State<AppState>, 204 205 auth: Auth<Active>, 205 206 ) -> 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, 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, 209 212 Err(e) => { 210 213 error!("DB error fetching TOTP status: {:?}", e); 211 214 return Err(ApiError::InternalError(None)); ··· 307 310 did: &crate::types::Did, 308 311 code: &str, 309 312 ) -> bool { 313 + use tranquil_db_traits::TotpRecordState; 314 + 310 315 let code = code.trim(); 311 316 312 317 if is_backup_code_format(code) { 313 318 return verify_backup_code_for_user(state, did, code).await; 314 319 } 315 320 316 - let totp_record = match state.user_repo.get_totp_record(did).await { 317 - Ok(Some(row)) if row.verified => row, 321 + let verified_record = match state.user_repo.get_totp_record_state(did).await { 322 + Ok(Some(TotpRecordState::Verified(record))) => record, 318 323 _ => return false, 319 324 }; 320 325 321 326 let secret = match decrypt_totp_secret( 322 - &totp_record.secret_encrypted, 323 - totp_record.encryption_version, 327 + &verified_record.secret_encrypted, 328 + verified_record.encryption_version, 324 329 ) { 325 330 Ok(s) => s, 326 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 88 return Err(ApiError::IdentifierMismatch); 89 89 } 90 90 91 - if !user.email_verified { 91 + if !user.channel_verification.email { 92 92 state 93 93 .user_repo 94 94 .set_email_verified_flag(user.id) ··· 185 185 .log_db_err("during signup verification")? 186 186 .ok_or(ApiError::AccountNotFound)?; 187 187 188 - let is_verified = user.email_verified 189 - || user.discord_verified 190 - || user.telegram_verified 191 - || user.signal_verified; 188 + let is_verified = user.channel_verification.has_any_verified(); 192 189 if is_verified { 193 190 info!(did = %did, "Account already verified"); 194 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 87 state: &AppState, 88 88 user: &'a AuthenticatedUser, 89 89 ) -> Result<MfaVerified<'a>, Response> { 90 - use chrono::Utc; 91 90 use crate::api::server::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response}; 91 + use chrono::Utc; 92 92 93 - let status = state.session_repo.get_session_mfa_status(&user.did).await.ok().flatten(); 93 + let status = state 94 + .session_repo 95 + .get_session_mfa_status(&user.did) 96 + .await 97 + .ok() 98 + .flatten(); 94 99 95 100 match status { 96 101 Some(s) => { ··· 177 182 code: &str, 178 183 ) -> Result<MfaVerified<'a>, crate::api::error::ApiError> { 179 184 use crate::auth::{decrypt_totp_secret, is_backup_code_format, verify_totp_code}; 185 + use tranquil_db_traits::TotpRecordState; 180 186 181 187 let code = code.trim(); 182 188 183 189 if is_backup_code_format(code) { 184 - let backup_codes = state.user_repo.get_unused_backup_codes(&user.did).await.ok().unwrap_or_default(); 190 + let backup_codes = state 191 + .user_repo 192 + .get_unused_backup_codes(&user.did) 193 + .await 194 + .ok() 195 + .unwrap_or_default(); 185 196 let code_upper = code.to_uppercase(); 186 197 187 198 let matched = backup_codes ··· 199 210 }; 200 211 } 201 212 202 - let totp_record = match state.user_repo.get_totp_record(&user.did).await { 203 - Ok(Some(row)) if row.verified => row, 213 + let verified_record = match state.user_repo.get_totp_record_state(&user.did).await { 214 + Ok(Some(TotpRecordState::Verified(record))) => record, 204 215 _ => { 205 216 return Err(crate::api::error::ApiError::TotpNotEnabled); 206 217 } 207 218 }; 208 219 209 220 let secret = decrypt_totp_secret( 210 - &totp_record.secret_encrypted, 211 - totp_record.encryption_version, 221 + &verified_record.secret_encrypted, 222 + verified_record.encryption_version, 212 223 ) 213 224 .map_err(|_| crate::api::error::ApiError::InternalError(None))?; 214 225
+9 -2
crates/tranquil-pds/src/auth/mod.rs
··· 10 10 use tranquil_db::UserRepository; 11 11 use tranquil_db_traits::OAuthRepository; 12 12 13 + pub mod account_verified; 13 14 pub mod extractor; 15 + pub mod login_identifier; 14 16 pub mod mfa_verified; 15 17 pub mod scope_check; 16 18 pub mod scope_verified; ··· 18 20 pub mod verification_token; 19 21 pub mod webauthn; 20 22 23 + pub use login_identifier::{BareLoginIdentifier, NormalizedLoginIdentifier}; 24 + 25 + pub use account_verified::{AccountVerified, require_not_migrated, require_verified_or_delegated}; 21 26 pub use extractor::{ 22 27 Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown, 23 28 Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header, ··· 27 32 require_reauth_window_if_available, verify_password_mfa, verify_totp_mfa, 28 33 }; 29 34 pub use scope_verified::{ 30 - AccountManage, AccountRead, BlobUpload, IdentityAccess, RepoCreate, RepoDelete, RepoUpdate, 31 - RpcCall, ScopeAction, ScopeVerificationError, ScopeVerified, VerifyScope, 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, 32 39 }; 33 40 pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; 34 41
+230 -5
crates/tranquil-pds/src/auth/scope_verified.rs
··· 1 1 use std::marker::PhantomData; 2 + use std::ops::Deref; 2 3 3 4 use axum::response::{IntoResponse, Response}; 4 5 5 6 use crate::api::error::ApiError; 6 - use crate::oauth::scopes::{AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions}; 7 + use crate::oauth::scopes::{ 8 + AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, 9 + }; 10 + use crate::types::Did; 7 11 8 12 use super::AuthenticatedUser; 9 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 + 10 88 #[derive(Debug)] 11 89 pub struct ScopeVerificationError { 12 90 message: String, ··· 40 118 41 119 mod private { 42 120 pub trait Sealed {} 121 + pub trait RepoScopeSealed {} 122 + pub trait BlobScopeSealed {} 43 123 } 44 124 45 125 pub trait ScopeAction: private::Sealed {} 46 126 127 + pub trait RepoScopeAction: ScopeAction + private::RepoScopeSealed {} 128 + 129 + pub trait BlobScopeAction: ScopeAction + private::BlobScopeSealed {} 130 + 47 131 pub struct RepoCreate; 48 132 pub struct RepoUpdate; 49 133 pub struct RepoDelete; 134 + pub struct RepoUpsert; 50 135 pub struct BlobUpload; 51 136 pub struct RpcCall; 52 137 pub struct AccountRead; ··· 56 141 impl private::Sealed for RepoCreate {} 57 142 impl private::Sealed for RepoUpdate {} 58 143 impl private::Sealed for RepoDelete {} 144 + impl private::Sealed for RepoUpsert {} 59 145 impl private::Sealed for BlobUpload {} 60 146 impl private::Sealed for RpcCall {} 61 147 impl private::Sealed for AccountRead {} 62 148 impl private::Sealed for AccountManage {} 63 149 impl private::Sealed for IdentityAccess {} 64 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 + 65 158 impl ScopeAction for RepoCreate {} 66 159 impl ScopeAction for RepoUpdate {} 67 160 impl ScopeAction for RepoDelete {} 161 + impl ScopeAction for RepoUpsert {} 68 162 impl ScopeAction for BlobUpload {} 69 163 impl ScopeAction for RpcCall {} 70 164 impl ScopeAction for AccountRead {} 71 165 impl ScopeAction for AccountManage {} 72 166 impl ScopeAction for IdentityAccess {} 73 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 + 74 175 pub struct ScopeVerified<'a, A: ScopeAction> { 75 176 user: &'a AuthenticatedUser, 76 177 _action: PhantomData<A>, ··· 81 182 self.user 82 183 } 83 184 84 - pub fn did(&self) -> &crate::types::Did { 85 - &self.user.did 185 + pub fn principal_did(&self) -> PrincipalDid { 186 + PrincipalDid(self.user.did.clone()) 86 187 } 87 188 189 + pub fn controller_did(&self) -> Option<ControllerDid> { 190 + self.user.controller_did.clone().map(ControllerDid) 191 + } 192 + 88 193 pub fn is_admin(&self) -> bool { 89 194 self.user.is_admin 90 195 } 196 + } 91 197 92 - pub fn controller_did(&self) -> Option<&crate::types::Did> { 93 - self.user.controller_did.as_ref() 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()) 94 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 + } 95 229 } 96 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 + 97 297 pub trait VerifyScope { 98 298 fn needs_scope_check(&self) -> bool; 99 299 fn permissions(&self) -> ScopePermissions; ··· 157 357 } 158 358 self.permissions() 159 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) 160 385 .map_err(|e| ScopeVerificationError::new(e.to_string()))?; 161 386 Ok(ScopeVerified { 162 387 user: self.as_ref(),
+1 -2
crates/tranquil-pds/src/auth/service.rs
··· 80 80 let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") 81 81 .unwrap_or_else(|_| "https://plc.directory".to_string()); 82 82 83 - let pds_hostname = 84 - pds_hostname(); 83 + let pds_hostname = pds_hostname(); 85 84 let pds_did = format!("did:web:{}", pds_hostname); 86 85 87 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 7 use std::time::Duration; 8 8 use tokio::sync::{broadcast, watch}; 9 9 use tracing::{debug, error, info, warn}; 10 + use tranquil_db_traits::RepoEventType; 10 11 11 12 const NOTIFY_THRESHOLD_SECS: u64 = 20 * 60; 12 13 ··· 161 162 result = firehose_rx.recv() => { 162 163 match result { 163 164 Ok(event) => { 164 - if event.event_type == "commit" { 165 + if event.event_type == RepoEventType::Commit { 165 166 crawlers.notify_of_update().await; 166 167 } 167 168 }
+6 -3
crates/tranquil-pds/src/delegation/mod.rs
··· 2 2 pub mod scopes; 3 3 4 4 pub use roles::{ 5 - CanAddControllers, CanControlAccounts, verify_can_add_controllers, verify_can_be_controller, 6 - verify_can_control_accounts, 5 + CanAddControllers, CanBeController, CanControlAccounts, verify_can_add_controllers, 6 + verify_can_be_controller, verify_can_control_accounts, 7 7 }; 8 - pub use scopes::{SCOPE_PRESETS, ScopePreset, intersect_scopes}; 8 + pub use scopes::{ 9 + InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, 10 + intersect_scopes, validate_delegation_scopes, 11 + }; 9 12 pub use tranquil_db_traits::DelegationActionType;
+31 -11
crates/tranquil-pds/src/delegation/roles.rs
··· 13 13 user: &'a AuthenticatedUser, 14 14 } 15 15 16 + pub struct CanBeController<'a> { 17 + controller_did: &'a Did, 18 + } 19 + 16 20 impl<'a> CanAddControllers<'a> { 17 21 pub fn did(&self) -> &Did { 18 22 &self.user.did ··· 33 37 } 34 38 } 35 39 40 + impl<'a> CanBeController<'a> { 41 + pub fn did(&self) -> &Did { 42 + self.controller_did 43 + } 44 + } 45 + 36 46 pub async fn verify_can_add_controllers<'a>( 37 47 state: &AppState, 38 48 user: &'a AuthenticatedUser, ··· 45 55 Ok(false) => Ok(CanAddControllers { user }), 46 56 Err(e) => { 47 57 tracing::error!("Failed to check delegation status: {:?}", e); 48 - Err(ApiError::InternalError(Some("Failed to verify delegation status".into())) 49 - .into_response()) 58 + Err( 59 + ApiError::InternalError(Some("Failed to verify delegation status".into())) 60 + .into_response(), 61 + ) 50 62 } 51 63 } 52 64 } ··· 63 75 Ok(false) => Ok(CanControlAccounts { user }), 64 76 Err(e) => { 65 77 tracing::error!("Failed to check controller status: {:?}", e); 66 - Err(ApiError::InternalError(Some("Failed to verify controller status".into())) 67 - .into_response()) 78 + Err( 79 + ApiError::InternalError(Some("Failed to verify controller status".into())) 80 + .into_response(), 81 + ) 68 82 } 69 83 } 70 84 } 71 85 72 - pub async fn verify_can_be_controller( 86 + pub async fn verify_can_be_controller<'a>( 73 87 state: &AppState, 74 - controller_did: &Did, 75 - ) -> Result<(), Response> { 76 - match state.delegation_repo.has_any_controllers(controller_did).await { 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 + { 77 95 Ok(true) => Err(ApiError::InvalidDelegation( 78 96 "Cannot add a controlled account as a controller".into(), 79 97 ) 80 98 .into_response()), 81 - Ok(false) => Ok(()), 99 + Ok(false) => Ok(CanBeController { controller_did }), 82 100 Err(e) => { 83 101 tracing::error!("Failed to check controller status: {:?}", e); 84 - Err(ApiError::InternalError(Some("Failed to verify controller status".into())) 85 - .into_response()) 102 + Err( 103 + ApiError::InternalError(Some("Failed to verify controller status".into())) 104 + .into_response(), 105 + ) 86 106 } 87 107 } 88 108 }
+25 -82
crates/tranquil-pds/src/oauth/endpoints/authorize.rs
··· 1 + use crate::auth::{BareLoginIdentifier, NormalizedLoginIdentifier}; 1 2 use crate::comms::{channel_display_name, comms_repo::enqueue_2fa_code}; 2 3 use crate::oauth::{ 3 4 AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId, ··· 252 253 if let Some(ref login_hint) = request_data.parameters.login_hint { 253 254 tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation"); 254 255 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 - }; 256 + let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles); 262 257 tracing::info!(normalized = %normalized, "Normalized login_hint"); 263 258 264 259 match state 265 260 .user_repo 266 - .get_login_check_by_handle_or_email(&normalized) 261 + .get_login_check_by_handle_or_email(normalized.as_str()) 267 262 .await 268 263 { 269 264 Ok(Some(user)) => { ··· 532 527 )) 533 528 }; 534 529 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 - }; 530 + let normalized_username = 531 + NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles); 546 532 tracing::debug!( 547 533 original_username = %form.username, 548 534 normalized_username = %normalized_username, ··· 551 537 ); 552 538 let user = match state 553 539 .user_repo 554 - .get_login_info_by_handle_or_email(&normalized_username) 540 + .get_login_info_by_handle_or_email(normalized_username.as_str()) 555 541 .await 556 542 { 557 543 Ok(Some(u)) => u, ··· 570 556 if user.takedown_ref.is_some() { 571 557 return show_login_error("This account has been taken down.", json_response); 572 558 } 573 - let is_verified = user.email_verified 574 - || user.discord_verified 575 - || user.telegram_verified 576 - || user.signal_verified; 559 + let is_verified = user.channel_verification.has_any_verified(); 577 560 if !is_verified { 578 561 return show_login_error( 579 562 "Please verify your account before logging in.", ··· 581 564 ); 582 565 } 583 566 584 - if user.account_type == "delegated" { 567 + if user.account_type.is_delegated() { 585 568 if state 586 569 .oauth_repo 587 570 .set_authorization_did(&form_request_id, &user.did, None) ··· 971 954 ); 972 955 } 973 956 }; 974 - let is_verified = user.email_verified 975 - || user.discord_verified 976 - || user.telegram_verified 977 - || user.signal_verified; 957 + let is_verified = user.channel_verification.has_any_verified(); 978 958 if !is_verified { 979 959 return json_error( 980 960 StatusCode::FORBIDDEN, ··· 1407 1387 Ok(flow) => match flow.require_user() { 1408 1388 Ok(u) => u, 1409 1389 Err(_) => { 1410 - return json_error( 1411 - StatusCode::FORBIDDEN, 1412 - "access_denied", 1413 - "Not authenticated", 1414 - ); 1390 + return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated"); 1415 1391 } 1416 1392 }, 1417 1393 Err(_) => { ··· 1456 1432 }; 1457 1433 1458 1434 let effective_scope_str = if let Some(ref grant) = delegation_grant { 1459 - crate::delegation::intersect_scopes(requested_scope_str, &grant.granted_scopes) 1435 + crate::delegation::intersect_scopes(requested_scope_str, grant.granted_scopes.as_str()) 1460 1436 } else { 1461 1437 requested_scope_str.to_string() 1462 1438 }; ··· 1555 1531 let level = if let Some(ref grant) = delegation_grant { 1556 1532 let preset = crate::delegation::SCOPE_PRESETS 1557 1533 .iter() 1558 - .find(|p| p.scopes == grant.granted_scopes); 1534 + .find(|p| p.scopes == grant.granted_scopes.as_str()); 1559 1535 preset 1560 1536 .map(|p| p.label.to_string()) 1561 1537 .unwrap_or_else(|| "Custom".to_string()) ··· 1665 1641 }; 1666 1642 1667 1643 let effective_scope_str = if let Some(ref grant) = delegation_grant { 1668 - crate::delegation::intersect_scopes(original_scope_str, &grant.granted_scopes) 1644 + crate::delegation::intersect_scopes(original_scope_str, grant.granted_scopes.as_str()) 1669 1645 } else { 1670 1646 original_scope_str.to_string() 1671 1647 }; ··· 1925 1901 StatusCode::TOO_MANY_REQUESTS, 1926 1902 "RateLimitExceeded", 1927 1903 "Too many verification attempts. Please try again in a few minutes.", 1928 - ) 1904 + ); 1929 1905 } 1930 1906 }; 1931 1907 let totp_valid = ··· 2017 1993 Query(query): Query<CheckPasskeysQuery>, 2018 1994 ) -> Response { 2019 1995 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 - }; 1996 + let bare_identifier = 1997 + BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles); 2031 1998 2032 1999 let user = state 2033 2000 .user_repo 2034 - .get_login_check_by_handle_or_email(&normalized_identifier) 2001 + .get_login_check_by_handle_or_email(bare_identifier.as_str()) 2035 2002 .await; 2036 2003 2037 2004 let has_passkeys = match user { ··· 2058 2025 Query(query): Query<CheckPasskeysQuery>, 2059 2026 ) -> Response { 2060 2027 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 - }; 2028 + let normalized_identifier = 2029 + NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles); 2070 2030 2071 2031 let user = state 2072 2032 .user_repo 2073 - .get_login_check_by_handle_or_email(&normalized_identifier) 2033 + .get_login_check_by_handle_or_email(normalized_identifier.as_str()) 2074 2034 .await; 2075 2035 2076 2036 let (has_passkeys, has_totp, has_password, is_delegated, did): ( ··· 2173 2133 } 2174 2134 2175 2135 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 - }; 2136 + let normalized_username = 2137 + NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles); 2187 2138 2188 2139 let user = match state 2189 2140 .user_repo 2190 - .get_login_info_by_handle_or_email(&normalized_username) 2141 + .get_login_info_by_handle_or_email(normalized_username.as_str()) 2191 2142 .await 2192 2143 { 2193 2144 Ok(Some(u)) => u, ··· 2235 2186 .into_response(); 2236 2187 } 2237 2188 2238 - let is_verified = user.email_verified 2239 - || user.discord_verified 2240 - || user.telegram_verified 2241 - || user.signal_verified; 2189 + let is_verified = user.channel_verification.has_any_verified(); 2242 2190 2243 2191 if !is_verified { 2244 2192 return ( ··· 3345 3293 } 3346 3294 3347 3295 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 - } 3296 + Ok(Some(info)) => info.channel_verification.has_any_verified(), 3354 3297 Ok(None) => { 3355 3298 return ( 3356 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 78 .as_ref() 79 79 .filter(|s| !s.is_empty()) 80 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())?; 81 + let code_challenge_method = 82 + parse_code_challenge_method(request.code_challenge_method.as_deref())?; 82 83 let client_cache = ClientMetadataCache::new(3600); 83 84 let client_metadata = client_cache.get(&request.client_id).await?; 84 85 client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
+9 -16
crates/tranquil-pds/src/oauth/endpoints/token/grants.rs
··· 52 52 .map_err(crate::oauth::db_err_to_oauth)? 53 53 .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; 54 54 55 - let flow = AuthFlow::from_request_data(auth_request).map_err(|_| { 56 - OAuthError::InvalidGrant("Authorization code has expired".to_string()) 57 - })?; 55 + let flow = AuthFlow::from_request_data(auth_request) 56 + .map_err(|_| OAuthError::InvalidGrant("Authorization code has expired".to_string()))?; 58 57 59 - let authorized = flow.require_authorized().map_err(|_| { 60 - OAuthError::InvalidGrant("Authorization not completed".to_string()) 61 - })?; 58 + let authorized = flow 59 + .require_authorized() 60 + .map_err(|_| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; 62 61 63 62 if let Some(request_client_id) = &request.client_auth.client_id 64 63 && request_client_id != &authorized.client_id ··· 99 98 let dpop_jkt = if let Some(proof) = &dpop_proof { 100 99 let config = AuthConfig::get(); 101 100 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 102 - let pds_hostname = 103 - pds_hostname(); 101 + let pds_hostname = pds_hostname(); 104 102 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 105 103 let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; 106 104 if !state ··· 146 144 .ok() 147 145 .flatten(); 148 146 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); 147 + let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto"); 148 + let intersected = intersect_scopes(requested, granted_scopes.as_str()); 155 149 (Some(intersected), Some(controller.clone())) 156 150 } else { 157 151 (authorized.parameters.scope.clone(), None) ··· 337 331 let dpop_jkt = if let Some(proof) = &dpop_proof { 338 332 let config = AuthConfig::get(); 339 333 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 340 - let pds_hostname = 341 - pds_hostname(); 334 + let pds_hostname = pds_hostname(); 342 335 let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 343 336 let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; 344 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 186 .and_then(|s| s.as_str()) 187 187 .map(|s| s.parse::<Did>()) 188 188 .transpose() 189 - .map_err(|_| OAuthError::InvalidToken("Invalid act.sub claim (not a valid DID)".to_string()))?; 189 + .map_err(|_| { 190 + OAuthError::InvalidToken("Invalid act.sub claim (not a valid DID)".to_string()) 191 + })?; 190 192 Ok(OAuthTokenInfo { 191 193 did, 192 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 6 }; 7 7 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 8 8 use serde::{Deserialize, Serialize}; 9 - use tranquil_db_traits::SsoProviderType; 9 + use tranquil_db_traits::{SsoAction, SsoProviderType}; 10 10 use tranquil_types::RequestId; 11 11 12 12 use super::config::SsoConfig; ··· 101 101 .get_provider(provider_type) 102 102 .ok_or(ApiError::SsoProviderNotEnabled)?; 103 103 104 - let action = input.action.as_deref().unwrap_or("login"); 105 - if !["login", "link", "register"].contains(&action) { 106 - return Err(ApiError::SsoInvalidAction); 107 - } 104 + let action = input 105 + .action 106 + .as_deref() 107 + .map(SsoAction::parse) 108 + .unwrap_or(Some(SsoAction::Login)) 109 + .ok_or(ApiError::SsoInvalidAction)?; 108 110 109 - let is_standalone = action == "register" && input.request_uri.is_none(); 111 + let is_standalone = action == SsoAction::Register && input.request_uri.is_none(); 110 112 let request_uri = input 111 113 .request_uri 112 114 .clone() 113 115 .unwrap_or_else(|| "standalone".to_string()); 114 116 115 117 let auth_did = match action { 116 - "link" => { 118 + SsoAction::Link => { 117 119 let auth_header = headers 118 120 .get(axum::http::header::AUTHORIZATION) 119 121 .and_then(|v| v.to_str().ok()); ··· 128 130 .map_err(|_| ApiError::SsoNotAuthenticated)?; 129 131 Some(auth_user.did) 130 132 } 131 - "register" if is_standalone => None, 133 + SsoAction::Register if is_standalone => None, 132 134 _ => { 133 135 let request_id = RequestId::new(request_uri.clone()); 134 136 let _request_data = state ··· 317 319 } 318 320 }; 319 321 320 - match auth_state.action.as_str() { 321 - "login" => { 322 + match auth_state.action { 323 + SsoAction::Login => { 322 324 handle_sso_login( 323 325 state, 324 326 &auth_state.request_uri, ··· 327 329 ) 328 330 .await 329 331 } 330 - "link" => { 332 + SsoAction::Link => { 331 333 let did = match auth_state.did { 332 334 Some(d) => d, 333 335 None => return redirect_to_error("Not authenticated"), 334 336 }; 335 337 handle_sso_link(state, did, auth_state.provider, &user_info).await 336 338 } 337 - "register" => { 339 + SsoAction::Register => { 338 340 handle_sso_register( 339 341 state, 340 342 &auth_state.request_uri, ··· 343 345 ) 344 346 .await 345 347 } 346 - _ => redirect_to_error("Unknown SSO action"), 347 348 } 348 349 } 349 350 ··· 420 421 }; 421 422 422 423 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 - } 424 + Ok(Some(info)) => info.channel_verification.has_any_verified(), 429 425 Ok(None) => { 430 426 tracing::error!("User not found for SSO login: {}", identity.did); 431 427 return redirect_to_error("Account not found"); ··· 477 473 "SSO login successful" 478 474 ); 479 475 480 - let has_totp = match state.user_repo.get_totp_record(&identity.did).await { 481 - Ok(Some(record)) => record.verified, 482 - _ => false, 483 - }; 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 + ); 484 480 485 481 if has_totp { 486 482 return Redirect::to(&format!( ··· 648 644 id: id.id.to_string(), 649 645 provider: id.provider.as_str().to_string(), 650 646 provider_name: id.provider.display_name().to_string(), 651 - provider_username: id.provider_username, 652 - provider_email: id.provider_email, 647 + provider_username: id.provider_username.map(|u| u.into_inner()), 648 + provider_email: id.provider_email.map(|e| e.into_inner()), 653 649 created_at: id.created_at.to_rfc3339(), 654 650 last_login_at: id.last_login_at.map(|t| t.to_rfc3339()), 655 651 }) ··· 752 748 Ok(Json(PendingRegistrationResponse { 753 749 request_uri: pending.request_uri, 754 750 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, 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()), 758 754 provider_email_verified: pending.provider_email_verified, 759 755 })) 760 756 } ··· 885 881 let email = input 886 882 .email 887 883 .clone() 888 - .or_else(|| pending_preview.provider_email.clone()) 884 + .or_else(|| pending_preview.provider_email.clone().map(|e| e.into_inner())) 889 885 .map(|e| e.trim().to_string()) 890 886 .filter(|e| !e.is_empty()); 891 887 match email { ··· 911 907 let email = input 912 908 .email 913 909 .clone() 914 - .or_else(|| pending_preview.provider_email.clone()) 910 + .or_else(|| pending_preview.provider_email.clone().map(|e| e.into_inner())) 915 911 .map(|e| e.trim().to_string()) 916 912 .filter(|e| !e.is_empty()); 917 913 ··· 928 924 None => None, 929 925 }; 930 926 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); 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), 939 931 } 940 932 } else { 941 933 let invite_required = std::env::var("INVITE_CODE_REQUIRED") ··· 944 936 if invite_required { 945 937 return Err(ApiError::InviteCodeRequired); 946 938 } 947 - } 939 + None 940 + }; 948 941 949 942 let handle_typed = crate::types::Handle::new_unchecked(&handle); 950 943 let reserved = state ··· 1116 1109 invite_code: input.invite_code.clone(), 1117 1110 birthdate_pref, 1118 1111 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(), 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()), 1122 1115 sso_provider_email_verified: pending_preview.provider_email_verified, 1123 1116 pending_registration_token: input.token.clone(), 1124 1117 }; ··· 1151 1144 { 1152 1145 tracing::warn!("Failed to sequence identity event for {}: {}", did, e); 1153 1146 } 1154 - if let Err(e) = 1155 - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await 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 1156 1153 { 1157 1154 tracing::warn!("Failed to sequence account event for {}: {}", did, e); 1158 1155 } ··· 1189 1186 user_id: create_result.user_id, 1190 1187 name: app_password_name.clone(), 1191 1188 password_hash: app_password_hash, 1192 - privileged: false, 1189 + privilege: tranquil_db_traits::AppPasswordPrivilege::Standard, 1193 1190 scopes: None, 1194 1191 created_by_controller_did: None, 1195 1192 }; ··· 1232 1229 1233 1230 let channel_auto_verified = verification_channel == "email" 1234 1231 && pending_preview.provider_email_verified 1235 - && pending_preview.provider_email.as_ref() == email.as_ref(); 1232 + && pending_preview.provider_email.as_ref().map(|e| e.as_str()) == email.as_deref(); 1236 1233 1237 1234 if channel_auto_verified { 1238 1235 let _ = state ··· 1276 1273 refresh_jti: refresh_meta.jti.clone(), 1277 1274 access_expires_at: access_meta.expires_at, 1278 1275 refresh_expires_at: refresh_meta.expires_at, 1279 - legacy_login: false, 1276 + login_type: tranquil_db_traits::LoginType::Modern, 1280 1277 mfa_verified: false, 1281 1278 scope: None, 1282 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 19 const MAX_REPO_BLOCKS_TRAVERSAL: usize = 20_000; 20 20 21 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 - ) { 22 + let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str( 23 + headers, 24 + "Authorization", 25 + )) { 25 26 Some(t) => t, 26 27 None => return false, 27 28 };
+2 -3
crates/tranquil-pds/src/util.rs
··· 93 93 } 94 94 95 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 - }) 96 + PDS_HOSTNAME 97 + .get_or_init(|| std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())) 99 98 } 100 99 101 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 1 { 2 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", 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 4 "describe": { 5 5 "columns": [ 6 6 { ··· 20 20 }, 21 21 { 22 22 "ordinal": 3, 23 - "name": "event_type", 23 + "name": "event_type: RepoEventType", 24 24 "type_info": "Text" 25 25 }, 26 26 { ··· 96 96 true 97 97 ] 98 98 }, 99 - "hash": "805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651" 99 + "hash": "0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f" 100 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 1 { 2 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", 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 4 "describe": { 5 5 "columns": [ 6 6 { ··· 10 10 }, 11 11 { 12 12 "ordinal": 1, 13 - "name": "preferred_channel!", 14 - "type_info": "Text" 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 + } 15 27 }, 16 28 { 17 29 "ordinal": 2, ··· 51 63 }, 52 64 "nullable": [ 53 65 true, 54 - null, 66 + false, 55 67 true, 56 68 false, 57 69 true, ··· 60 72 false 61 73 ] 62 74 }, 63 - "hash": "426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded" 75 + "hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43" 64 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 1 { 2 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 ", 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 4 "describe": { 5 5 "columns": [ 6 6 { ··· 10 10 }, 11 11 { 12 12 "ordinal": 1, 13 - "name": "channel: String", 13 + "name": "channel: CommsChannel", 14 14 "type_info": { 15 15 "Custom": { 16 16 "name": "comms_channel", ··· 27 27 }, 28 28 { 29 29 "ordinal": 2, 30 - "name": "comms_type: String", 30 + "name": "comms_type: CommsType", 31 31 "type_info": { 32 32 "Custom": { 33 33 "name": "comms_type", ··· 52 52 }, 53 53 { 54 54 "ordinal": 3, 55 - "name": "status: String", 55 + "name": "status: CommsStatus", 56 56 "type_info": { 57 57 "Custom": { 58 58 "name": "comms_status", ··· 93 93 false 94 94 ] 95 95 }, 96 - "hash": "9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671" 96 + "hash": "25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86" 97 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 1 { 2 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 ", 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 4 "describe": { 5 5 "columns": [ 6 6 { ··· 82 82 }, 83 83 { 84 84 "ordinal": 13, 85 - "name": "account_type!", 86 - "type_info": "Text" 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 + } 87 97 } 88 98 ], 89 99 "parameters": { ··· 105 115 false, 106 116 false, 107 117 false, 108 - null 118 + false 109 119 ] 110 120 }, 111 - "hash": "445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015" 121 + "hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa" 112 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 1 { 2 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", 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 4 "describe": { 5 5 "columns": [ 6 6 { ··· 20 20 }, 21 21 { 22 22 "ordinal": 3, 23 - "name": "event_type", 23 + "name": "event_type: RepoEventType", 24 24 "type_info": "Text" 25 25 }, 26 26 { ··· 97 97 true 98 98 ] 99 99 }, 100 - "hash": "caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db" 100 + "hash": "b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b" 101 101 }
+3 -3
.sqlx/query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json
··· 1 1 { 2 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", 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 4 "describe": { 5 5 "columns": [ 6 6 { ··· 20 20 }, 21 21 { 22 22 "ordinal": 3, 23 - "name": "event_type", 23 + "name": "event_type: RepoEventType", 24 24 "type_info": "Text" 25 25 }, 26 26 { ··· 96 96 true 97 97 ] 98 98 }, 99 - "hash": "e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23" 99 + "hash": "b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903" 100 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 1 { 2 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", 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 4 "describe": { 5 5 "columns": [ 6 6 { ··· 20 20 }, 21 21 { 22 22 "ordinal": 3, 23 - "name": "event_type", 23 + "name": "event_type: RepoEventType", 24 24 "type_info": "Text" 25 25 }, 26 26 { ··· 97 97 true 98 98 ] 99 99 }, 100 - "hash": "8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec" 100 + "hash": "d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811" 101 101 }
+19 -7
.sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1", 3 + "query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\n FROM users WHERE id = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { 7 7 "ordinal": 0, 8 - "name": "preferred_comms_channel: String", 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", 9 19 "type_info": { 10 20 "Custom": { 11 21 "name": "comms_channel", ··· 21 31 } 22 32 }, 23 33 { 24 - "ordinal": 1, 25 - "name": "discord_id", 26 - "type_info": "Text" 34 + "ordinal": 3, 35 + "name": "preferred_locale", 36 + "type_info": "Varchar" 27 37 } 28 38 ], 29 39 "parameters": { 30 40 "Left": [ 31 - "Text" 41 + "Uuid" 32 42 ] 33 43 }, 34 44 "nullable": [ 45 + true, 46 + false, 35 47 false, 36 48 true 37 49 ] 38 50 }, 39 - "hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722" 51 + "hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee" 40 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 1 { 2 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", 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 4 "describe": { 5 5 "columns": [ 6 6 { ··· 20 20 }, 21 21 { 22 22 "ordinal": 3, 23 - "name": "event_type", 23 + "name": "event_type: RepoEventType", 24 24 "type_info": "Text" 25 25 }, 26 26 { ··· 97 97 true 98 98 ] 99 99 }, 100 - "hash": "605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc" 100 + "hash": "e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a" 101 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 5 use uuid::Uuid; 6 6 7 7 use crate::DbError; 8 + use crate::scope::DbScope; 8 9 9 10 #[derive(Debug, Clone, Serialize, Deserialize)] 10 11 pub struct DelegationGrant { 11 12 pub id: Uuid, 12 13 pub delegated_did: Did, 13 14 pub controller_did: Did, 14 - pub granted_scopes: String, 15 + pub granted_scopes: DbScope, 15 16 pub granted_at: DateTime<Utc>, 16 17 pub granted_by: Did, 17 18 pub revoked_at: Option<DateTime<Utc>>, ··· 22 23 pub struct DelegatedAccountInfo { 23 24 pub did: Did, 24 25 pub handle: Handle, 25 - pub granted_scopes: String, 26 + pub granted_scopes: DbScope, 26 27 pub granted_at: DateTime<Utc>, 27 28 } 28 29 ··· 30 31 pub struct ControllerInfo { 31 32 pub did: Did, 32 33 pub handle: Handle, 33 - pub granted_scopes: String, 34 + pub granted_scopes: DbScope, 34 35 pub granted_at: DateTime<Utc>, 35 36 pub is_active: bool, 36 37 } ··· 67 68 &self, 68 69 delegated_did: &Did, 69 70 controller_did: &Did, 70 - granted_scopes: &str, 71 + granted_scopes: &DbScope, 71 72 granted_by: &Did, 72 73 ) -> Result<Uuid, DbError>; 73 74 ··· 82 83 &self, 83 84 delegated_did: &Did, 84 85 controller_did: &Did, 85 - new_scopes: &str, 86 + new_scopes: &DbScope, 86 87 ) -> Result<bool, DbError>; 87 88 88 89 async fn get_delegation(
+59 -7
crates/tranquil-db-traits/src/infra.rs
··· 5 5 use uuid::Uuid; 6 6 7 7 use crate::DbError; 8 + use crate::invite_code::{InviteCodeError, ValidatedInviteCode}; 8 9 9 10 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 10 11 pub enum InviteCodeSortOrder { ··· 13 14 Usage, 14 15 } 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 + 16 52 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 17 53 #[sqlx(type_name = "comms_channel", rename_all = "snake_case")] 18 54 pub enum CommsChannel { ··· 72 108 pub struct InviteCodeInfo { 73 109 pub code: String, 74 110 pub available_uses: i32, 75 - pub disabled: bool, 111 + pub state: InviteCodeState, 76 112 pub for_account: Option<Did>, 77 113 pub created_at: DateTime<Utc>, 78 114 pub created_by: Option<Did>, ··· 95 131 pub created_at: DateTime<Utc>, 96 132 } 97 133 134 + impl InviteCodeRow { 135 + pub fn state(&self) -> InviteCodeState { 136 + InviteCodeState::from(self.disabled) 137 + } 138 + } 139 + 98 140 #[derive(Debug, Clone)] 99 141 pub struct ReservedSigningKey { 100 142 pub id: Uuid, ··· 148 190 149 191 async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError>; 150 192 151 - async fn is_invite_code_valid(&self, code: &str) -> Result<bool, DbError>; 193 + async fn validate_invite_code<'a>( 194 + &self, 195 + code: &'a str, 196 + ) -> Result<ValidatedInviteCode<'a>, InviteCodeError>; 152 197 153 - async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError>; 198 + async fn decrement_invite_code_uses( 199 + &self, 200 + code: &ValidatedInviteCode<'_>, 201 + ) -> Result<(), DbError>; 154 202 155 - async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError>; 203 + async fn record_invite_code_use( 204 + &self, 205 + code: &ValidatedInviteCode<'_>, 206 + used_by_user: Uuid, 207 + ) -> Result<(), DbError>; 156 208 157 209 async fn get_invite_codes_for_account( 158 210 &self, ··· 317 369 #[derive(Debug, Clone)] 318 370 pub struct NotificationHistoryRow { 319 371 pub created_at: DateTime<Utc>, 320 - pub channel: String, 321 - pub comms_type: String, 322 - pub status: String, 372 + pub channel: CommsChannel, 373 + pub comms_type: CommsType, 374 + pub status: CommsStatus, 323 375 pub subject: Option<String>, 324 376 pub body: String, 325 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 1 mod backlink; 2 2 mod backup; 3 3 mod blob; 4 + mod channel_verification; 4 5 mod delegation; 5 6 mod error; 6 7 mod infra; 8 + mod invite_code; 7 9 mod oauth; 8 10 mod repo; 11 + mod scope; 12 + mod sequence; 9 13 mod session; 10 14 mod sso; 11 15 mod user; ··· 16 20 OldBackupInfo, UserBackupInfo, 17 21 }; 18 22 pub use blob::{BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, MissingBlobInfo}; 23 + pub use channel_verification::ChannelVerificationStatus; 19 24 pub use delegation::{ 20 25 AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant, 21 26 DelegationRepository, ··· 23 28 pub use error::DbError; 24 29 pub use infra::{ 25 30 AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, InfraRepository, 26 - InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, NotificationHistoryRow, 27 - QueuedComms, ReservedSigningKey, 31 + InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, InviteCodeUse, 32 + NotificationHistoryRow, QueuedComms, ReservedSigningKey, 28 33 }; 34 + pub use invite_code::{InviteCodeError, ValidatedInviteCode}; 29 35 pub use oauth::{ 30 36 DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, RefreshTokenLookup, 31 - ScopePreference, TrustedDeviceRow, TwoFactorChallenge, 37 + ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, 32 38 }; 33 39 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, 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, 39 45 }; 46 + pub use scope::{DbScope, InvalidScopeError}; 47 + pub use sequence::{SequenceNumber, deserialize_optional_sequence}; 40 48 pub use session::{ 41 - AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem, 42 - SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, 49 + AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, RefreshSessionResult, 50 + SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData, 51 + SessionRepository, SessionToken, SessionTokenCreate, 43 52 }; 44 53 pub use sso::{ 45 - ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, 54 + ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction, SsoAuthState, 55 + SsoPendingRegistration, SsoProviderType, SsoRepository, 46 56 }; 47 57 pub use user::{ 48 - AccountSearchResult, CompletePasskeySetupInput, CreateAccountError, 58 + AccountSearchResult, AccountType, CompletePasskeySetupInput, CreateAccountError, 49 59 CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput, 50 60 CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides, 51 61 MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, 52 62 PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput, 53 63 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, 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, 61 72 };
+43 -12
crates/tranquil-db-traits/src/oauth.rs
··· 10 10 11 11 use crate::DbError; 12 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 + 13 44 #[derive(Debug, Clone, Serialize, Deserialize)] 14 45 pub struct ScopePreference { 15 46 pub scope: String, ··· 53 84 54 85 #[derive(Debug, Clone)] 55 86 pub struct OAuthSessionListItem { 56 - pub id: i32, 87 + pub id: TokenFamilyId, 57 88 pub token_id: TokenId, 58 89 pub created_at: DateTime<Utc>, 59 90 pub expires_at: DateTime<Utc>, ··· 62 93 63 94 pub enum RefreshTokenLookup { 64 95 Valid { 65 - db_id: i32, 96 + db_id: TokenFamilyId, 66 97 token_data: TokenData, 67 98 }, 68 99 InGracePeriod { 69 - db_id: i32, 100 + db_id: TokenFamilyId, 70 101 token_data: TokenData, 71 102 rotated_at: DateTime<Utc>, 72 103 }, 73 104 Used { 74 - original_token_id: i32, 105 + original_token_id: TokenFamilyId, 75 106 }, 76 107 Expired { 77 - db_id: i32, 108 + db_id: TokenFamilyId, 78 109 }, 79 110 NotFound, 80 111 } ··· 93 124 94 125 #[async_trait] 95 126 pub trait OAuthRepository: Send + Sync { 96 - async fn create_token(&self, data: &TokenData) -> Result<i32, DbError>; 127 + async fn create_token(&self, data: &TokenData) -> Result<TokenFamilyId, DbError>; 97 128 async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError>; 98 129 async fn get_token_by_refresh_token( 99 130 &self, 100 131 refresh_token: &RefreshToken, 101 - ) -> Result<Option<(i32, TokenData)>, DbError>; 132 + ) -> Result<Option<(TokenFamilyId, TokenData)>, DbError>; 102 133 async fn get_token_by_previous_refresh_token( 103 134 &self, 104 135 refresh_token: &RefreshToken, 105 - ) -> Result<Option<(i32, TokenData)>, DbError>; 136 + ) -> Result<Option<(TokenFamilyId, TokenData)>, DbError>; 106 137 async fn rotate_token( 107 138 &self, 108 - old_db_id: i32, 139 + old_db_id: TokenFamilyId, 109 140 new_refresh_token: &RefreshToken, 110 141 new_expires_at: DateTime<Utc>, 111 142 ) -> Result<(), DbError>; 112 143 async fn check_refresh_token_used( 113 144 &self, 114 145 refresh_token: &RefreshToken, 115 - ) -> Result<Option<i32>, DbError>; 146 + ) -> Result<Option<TokenFamilyId>, DbError>; 116 147 async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError>; 117 - async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError>; 148 + async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError>; 118 149 async fn list_tokens_for_user(&self, did: &Did) -> Result<Vec<TokenData>, DbError>; 119 150 async fn count_tokens_for_user(&self, did: &Did) -> Result<i64, DbError>; 120 151 async fn delete_oldest_tokens_for_user( ··· 274 305 ) -> Result<(), DbError>; 275 306 276 307 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>; 308 + async fn delete_session_by_id(&self, session_id: TokenFamilyId, did: &Did) -> Result<u64, DbError>; 278 309 async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>; 279 310 async fn delete_sessions_by_did_except( 280 311 &self,
+146 -24
crates/tranquil-db-traits/src/repo.rs
··· 5 5 use uuid::Uuid; 6 6 7 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 + } 8 118 9 119 #[derive(Debug, Clone, Serialize, Deserialize)] 10 120 pub struct RepoAccountInfo { ··· 49 159 50 160 #[derive(Debug, Clone)] 51 161 pub struct BrokenGenesisCommit { 52 - pub seq: i64, 162 + pub seq: SequenceNumber, 53 163 pub did: Did, 54 164 pub commit_cid: Option<CidLink>, 55 165 } ··· 69 179 70 180 #[derive(Debug, Clone, Serialize, Deserialize)] 71 181 pub struct RepoSeqEvent { 72 - pub seq: i64, 182 + pub seq: SequenceNumber, 73 183 } 74 184 75 185 #[derive(Debug, Clone, Serialize, Deserialize)] 76 186 pub struct SequencedEvent { 77 - pub seq: i64, 187 + pub seq: SequenceNumber, 78 188 pub did: Did, 79 189 pub created_at: DateTime<Utc>, 80 - pub event_type: String, 190 + pub event_type: RepoEventType, 81 191 pub commit_cid: Option<CidLink>, 82 192 pub prev_cid: Option<CidLink>, 83 193 pub prev_data_cid: Option<CidLink>, ··· 86 196 pub blocks_cids: Option<Vec<String>>, 87 197 pub handle: Option<Handle>, 88 198 pub active: Option<bool>, 89 - pub status: Option<String>, 199 + pub status: Option<AccountStatus>, 90 200 pub rev: Option<String>, 91 201 } 92 202 93 203 #[derive(Debug, Clone)] 94 204 pub struct CommitEventData { 95 205 pub did: Did, 96 - pub event_type: String, 206 + pub event_type: RepoEventType, 97 207 pub commit_cid: Option<CidLink>, 98 208 pub prev_cid: Option<CidLink>, 99 209 pub ops: Option<serde_json::Value>, ··· 283 393 284 394 async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>; 285 395 286 - async fn insert_commit_event(&self, data: &CommitEventData) -> Result<i64, DbError>; 396 + async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>; 287 397 288 398 async fn insert_identity_event( 289 399 &self, 290 400 did: &Did, 291 401 handle: Option<&Handle>, 292 - ) -> Result<i64, DbError>; 402 + ) -> Result<SequenceNumber, DbError>; 293 403 294 404 async fn insert_account_event( 295 405 &self, 296 406 did: &Did, 297 - active: bool, 298 - status: Option<&str>, 299 - ) -> Result<i64, DbError>; 407 + status: AccountStatus, 408 + ) -> Result<SequenceNumber, DbError>; 300 409 301 410 async fn insert_sync_event( 302 411 &self, 303 412 did: &Did, 304 413 commit_cid: &CidLink, 305 414 rev: Option<&str>, 306 - ) -> Result<i64, DbError>; 415 + ) -> Result<SequenceNumber, DbError>; 307 416 308 417 async fn insert_genesis_commit_event( 309 418 &self, ··· 311 420 commit_cid: &CidLink, 312 421 mst_root_cid: &CidLink, 313 422 rev: &str, 314 - ) -> Result<i64, DbError>; 423 + ) -> Result<SequenceNumber, DbError>; 315 424 316 - async fn update_seq_blocks_cids(&self, seq: i64, blocks_cids: &[String]) 317 - -> Result<(), DbError>; 425 + async fn update_seq_blocks_cids( 426 + &self, 427 + seq: SequenceNumber, 428 + blocks_cids: &[String], 429 + ) -> Result<(), DbError>; 318 430 319 - async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError>; 431 + async fn delete_sequences_except( 432 + &self, 433 + did: &Did, 434 + keep_seq: SequenceNumber, 435 + ) -> Result<(), DbError>; 320 436 321 - async fn get_max_seq(&self) -> Result<i64, DbError>; 437 + async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>; 322 438 323 - async fn get_min_seq_since(&self, since: DateTime<Utc>) -> Result<Option<i64>, DbError>; 439 + async fn get_min_seq_since( 440 + &self, 441 + since: DateTime<Utc>, 442 + ) -> Result<Option<SequenceNumber>, DbError>; 324 443 325 444 async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError>; 326 445 327 446 async fn get_events_since_seq( 328 447 &self, 329 - since_seq: i64, 448 + since_seq: SequenceNumber, 330 449 limit: Option<i64>, 331 450 ) -> Result<Vec<SequencedEvent>, DbError>; 332 451 333 452 async fn get_events_in_seq_range( 334 453 &self, 335 - start_seq: i64, 336 - end_seq: i64, 454 + start_seq: SequenceNumber, 455 + end_seq: SequenceNumber, 337 456 ) -> Result<Vec<SequencedEvent>, DbError>; 338 457 339 - async fn get_event_by_seq(&self, seq: i64) -> Result<Option<SequencedEvent>, DbError>; 458 + async fn get_event_by_seq( 459 + &self, 460 + seq: SequenceNumber, 461 + ) -> Result<Option<SequencedEvent>, DbError>; 340 462 341 463 async fn get_events_since_cursor( 342 464 &self, 343 - cursor: i64, 465 + cursor: SequenceNumber, 344 466 limit: i64, 345 467 ) -> Result<Vec<SequencedEvent>, DbError>; 346 468 ··· 359 481 async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid) 360 482 -> Result<Option<CidLink>, DbError>; 361 483 362 - async fn notify_update(&self, seq: i64) -> Result<(), DbError>; 484 + async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError>; 363 485 364 486 async fn import_repo_data( 365 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 5 6 6 use crate::DbError; 7 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 + 8 93 #[derive(Debug, Clone)] 9 94 pub struct SessionToken { 10 - pub id: i32, 95 + pub id: SessionId, 11 96 pub did: Did, 12 97 pub access_jti: String, 13 98 pub refresh_jti: String, 14 99 pub access_expires_at: DateTime<Utc>, 15 100 pub refresh_expires_at: DateTime<Utc>, 16 - pub legacy_login: bool, 101 + pub login_type: LoginType, 17 102 pub mfa_verified: bool, 18 103 pub scope: Option<String>, 19 104 pub controller_did: Option<Did>, ··· 29 114 pub refresh_jti: String, 30 115 pub access_expires_at: DateTime<Utc>, 31 116 pub refresh_expires_at: DateTime<Utc>, 32 - pub legacy_login: bool, 117 + pub login_type: LoginType, 33 118 pub mfa_verified: bool, 34 119 pub scope: Option<String>, 35 120 pub controller_did: Option<Did>, ··· 38 123 39 124 #[derive(Debug, Clone)] 40 125 pub struct SessionForRefresh { 41 - pub id: i32, 126 + pub id: SessionId, 42 127 pub did: Did, 43 128 pub scope: Option<String>, 44 129 pub controller_did: Option<Did>, ··· 48 133 49 134 #[derive(Debug, Clone)] 50 135 pub struct SessionListItem { 51 - pub id: i32, 136 + pub id: SessionId, 52 137 pub access_jti: String, 53 138 pub created_at: DateTime<Utc>, 54 139 pub refresh_expires_at: DateTime<Utc>, ··· 61 146 pub name: String, 62 147 pub password_hash: String, 63 148 pub created_at: DateTime<Utc>, 64 - pub privileged: bool, 149 + pub privilege: AppPasswordPrivilege, 65 150 pub scopes: Option<String>, 66 151 pub created_by_controller_did: Option<Did>, 67 152 } ··· 71 156 pub user_id: Uuid, 72 157 pub name: String, 73 158 pub password_hash: String, 74 - pub privileged: bool, 159 + pub privilege: AppPasswordPrivilege, 75 160 pub scopes: Option<String>, 76 161 pub created_by_controller_did: Option<Did>, 77 162 } 78 163 79 164 #[derive(Debug, Clone)] 80 165 pub struct SessionMfaStatus { 81 - pub legacy_login: bool, 166 + pub login_type: LoginType, 82 167 pub mfa_verified: bool, 83 168 pub last_reauth_at: Option<DateTime<Utc>>, 84 169 } ··· 93 178 #[derive(Debug, Clone)] 94 179 pub struct SessionRefreshData { 95 180 pub old_refresh_jti: String, 96 - pub session_id: i32, 181 + pub session_id: SessionId, 97 182 pub new_access_jti: String, 98 183 pub new_refresh_jti: String, 99 184 pub new_access_expires_at: DateTime<Utc>, ··· 102 187 103 188 #[async_trait] 104 189 pub trait SessionRepository: Send + Sync { 105 - async fn create_session(&self, data: &SessionTokenCreate) -> Result<i32, DbError>; 190 + async fn create_session(&self, data: &SessionTokenCreate) -> Result<SessionId, DbError>; 106 191 107 192 async fn get_session_by_access_jti( 108 193 &self, ··· 116 201 117 202 async fn update_session_tokens( 118 203 &self, 119 - session_id: i32, 204 + session_id: SessionId, 120 205 new_access_jti: &str, 121 206 new_refresh_jti: &str, 122 207 new_access_expires_at: DateTime<Utc>, ··· 125 210 126 211 async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError>; 127 212 128 - async fn delete_session_by_id(&self, session_id: i32) -> Result<u64, DbError>; 213 + async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError>; 129 214 130 215 async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>; 131 216 ··· 139 224 140 225 async fn get_session_access_jti_by_id( 141 226 &self, 142 - session_id: i32, 227 + session_id: SessionId, 143 228 did: &Did, 144 229 ) -> Result<Option<String>, DbError>; 145 230 ··· 155 240 app_password_name: &str, 156 241 ) -> Result<Vec<String>, DbError>; 157 242 158 - async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<i32>, DbError>; 243 + async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<SessionId>, DbError>; 159 244 160 245 async fn mark_refresh_token_used( 161 246 &self, 162 247 refresh_jti: &str, 163 - session_id: i32, 248 + session_id: SessionId, 164 249 ) -> Result<bool, DbError>; 165 250 166 251 async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
+147 -8
crates/tranquil-db-traits/src/sso.rs
··· 6 6 7 7 use crate::DbError; 8 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 + 9 114 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] 10 115 #[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")] 11 116 pub enum SsoProviderType { ··· 17 122 Apple, 18 123 } 19 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 + 20 159 impl SsoProviderType { 21 160 pub fn as_str(&self) -> &'static str { 22 161 match self { ··· 69 208 pub id: Uuid, 70 209 pub did: Did, 71 210 pub provider: SsoProviderType, 72 - pub provider_user_id: String, 73 - pub provider_username: Option<String>, 74 - pub provider_email: Option<String>, 211 + pub provider_user_id: ExternalUserId, 212 + pub provider_username: Option<ExternalUsername>, 213 + pub provider_email: Option<ExternalEmail>, 75 214 pub created_at: DateTime<Utc>, 76 215 pub updated_at: DateTime<Utc>, 77 216 pub last_login_at: Option<DateTime<Utc>>, ··· 82 221 pub state: String, 83 222 pub request_uri: String, 84 223 pub provider: SsoProviderType, 85 - pub action: String, 224 + pub action: SsoAction, 86 225 pub nonce: Option<String>, 87 226 pub code_verifier: Option<String>, 88 227 pub did: Option<Did>, ··· 95 234 pub token: String, 96 235 pub request_uri: String, 97 236 pub provider: SsoProviderType, 98 - pub provider_user_id: String, 99 - pub provider_username: Option<String>, 100 - pub provider_email: Option<String>, 237 + pub provider_user_id: ExternalUserId, 238 + pub provider_username: Option<ExternalUsername>, 239 + pub provider_email: Option<ExternalEmail>, 101 240 pub provider_email_verified: bool, 102 241 pub created_at: DateTime<Utc>, 103 242 pub expires_at: DateTime<Utc>, ··· 140 279 state: &str, 141 280 request_uri: &str, 142 281 provider: SsoProviderType, 143 - action: &str, 282 + action: SsoAction, 144 283 nonce: Option<&str>, 145 284 code_verifier: Option<&str>, 146 285 did: Option<&Did>,
+100 -34
crates/tranquil-db-traits/src/user.rs
··· 1 1 use async_trait::async_trait; 2 2 use chrono::{DateTime, Utc}; 3 + use serde::{Deserialize, Serialize}; 3 4 use tranquil_types::{Did, Handle}; 4 5 use uuid::Uuid; 5 6 6 - use crate::{CommsChannel, DbError, SsoProviderType}; 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 + } 7 21 8 22 #[derive(Debug, Clone)] 9 23 pub struct UserRow { ··· 62 76 pub preferred_comms_channel: CommsChannel, 63 77 pub deactivated_at: Option<DateTime<Utc>>, 64 78 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, 79 + pub channel_verification: ChannelVerificationStatus, 80 + pub account_type: AccountType, 70 81 } 71 82 72 83 #[derive(Debug, Clone)] ··· 74 85 pub id: Uuid, 75 86 pub two_factor_enabled: bool, 76 87 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, 88 + pub channel_verification: ChannelVerificationStatus, 81 89 } 82 90 83 91 #[async_trait] ··· 202 210 did: &Did, 203 211 ) -> Result<Option<UserIdHandleEmail>, DbError>; 204 212 205 - async fn update_preferred_comms_channel(&self, did: &Did, channel: &str) 206 - -> Result<(), DbError>; 213 + async fn update_preferred_comms_channel( 214 + &self, 215 + did: &Did, 216 + channel: CommsChannel, 217 + ) -> Result<(), DbError>; 207 218 208 219 async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError>; 209 220 ··· 292 303 293 304 async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>; 294 305 306 + async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError>; 307 + 295 308 async fn upsert_totp_secret( 296 309 &self, 297 310 did: &Did, ··· 560 573 pub struct UserCommsPrefs { 561 574 pub email: Option<String>, 562 575 pub handle: Handle, 563 - pub preferred_channel: String, 576 + pub preferred_channel: CommsChannel, 564 577 pub preferred_locale: Option<String>, 565 578 } 566 579 ··· 611 624 pub password_hash: Option<String>, 612 625 pub deactivated_at: Option<DateTime<Utc>>, 613 626 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, 627 + pub channel_verification: ChannelVerificationStatus, 618 628 } 619 629 620 630 #[derive(Debug, Clone)] 621 631 pub struct NotificationPrefs { 622 632 pub email: String, 623 - pub preferred_channel: String, 633 + pub preferred_channel: CommsChannel, 624 634 pub discord_id: Option<String>, 625 635 pub discord_verified: bool, 626 636 pub telegram_username: Option<String>, ··· 641 651 pub id: Uuid, 642 652 pub handle: Handle, 643 653 pub email: Option<String>, 644 - pub email_verified: bool, 645 - pub discord_verified: bool, 646 - pub telegram_verified: bool, 647 - pub signal_verified: bool, 654 + pub channel_verification: ChannelVerificationStatus, 648 655 } 649 656 650 657 #[derive(Debug, Clone)] ··· 675 682 pub verified: bool, 676 683 } 677 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 + 678 753 #[derive(Debug, Clone)] 679 754 pub struct StoredBackupCode { 680 755 pub id: Uuid, ··· 685 760 pub struct UserSessionInfo { 686 761 pub handle: Handle, 687 762 pub email: Option<String>, 688 - pub email_verified: bool, 689 763 pub is_admin: bool, 690 764 pub deactivated_at: Option<DateTime<Utc>>, 691 765 pub takedown_ref: Option<String>, 692 766 pub preferred_locale: Option<String>, 693 767 pub preferred_comms_channel: CommsChannel, 694 - pub discord_verified: bool, 695 - pub telegram_verified: bool, 696 - pub signal_verified: bool, 768 + pub channel_verification: ChannelVerificationStatus, 697 769 pub migrated_to_pds: Option<String>, 698 770 pub migrated_at: Option<DateTime<Utc>>, 699 771 } ··· 713 785 pub email: Option<String>, 714 786 pub deactivated_at: Option<DateTime<Utc>>, 715 787 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, 788 + pub channel_verification: ChannelVerificationStatus, 720 789 pub allow_legacy_login: bool, 721 790 pub migrated_to_pds: Option<String>, 722 791 pub preferred_comms_channel: CommsChannel, ··· 748 817 pub discord_id: Option<String>, 749 818 pub telegram_username: Option<String>, 750 819 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, 820 + pub channel_verification: ChannelVerificationStatus, 755 821 } 756 822 757 823 #[derive(Debug, Clone)]
+9 -9
crates/tranquil-db/src/postgres/delegation.rs
··· 1 1 use async_trait::async_trait; 2 2 use sqlx::PgPool; 3 3 use tranquil_db_traits::{ 4 - AuditLogEntry, ControllerInfo, DbError, DelegatedAccountInfo, DelegationActionType, 4 + AuditLogEntry, ControllerInfo, DbError, DbScope, DelegatedAccountInfo, DelegationActionType, 5 5 DelegationGrant, DelegationRepository, 6 6 }; 7 7 use tranquil_types::Did; ··· 80 80 &self, 81 81 delegated_did: &Did, 82 82 controller_did: &Did, 83 - granted_scopes: &str, 83 + granted_scopes: &DbScope, 84 84 granted_by: &Did, 85 85 ) -> Result<Uuid, DbError> { 86 86 let id = sqlx::query_scalar!( ··· 91 91 "#, 92 92 delegated_did.as_str(), 93 93 controller_did.as_str(), 94 - granted_scopes, 94 + granted_scopes.as_str(), 95 95 granted_by.as_str() 96 96 ) 97 97 .fetch_one(&self.pool) ··· 128 128 &self, 129 129 delegated_did: &Did, 130 130 controller_did: &Did, 131 - new_scopes: &str, 131 + new_scopes: &DbScope, 132 132 ) -> Result<bool, DbError> { 133 133 let result = sqlx::query!( 134 134 r#" ··· 136 136 SET granted_scopes = $1 137 137 WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL 138 138 "#, 139 - new_scopes, 139 + new_scopes.as_str(), 140 140 delegated_did.as_str(), 141 141 controller_did.as_str() 142 142 ) ··· 170 170 id: r.id, 171 171 delegated_did: r.delegated_did.into(), 172 172 controller_did: r.controller_did.into(), 173 - granted_scopes: r.granted_scopes, 173 + granted_scopes: DbScope::from_db_unchecked(r.granted_scopes), 174 174 granted_at: r.granted_at, 175 175 granted_by: r.granted_by.into(), 176 176 revoked_at: r.revoked_at, ··· 206 206 .map(|r| ControllerInfo { 207 207 did: r.did.into(), 208 208 handle: r.handle.into(), 209 - granted_scopes: r.granted_scopes, 209 + granted_scopes: DbScope::from_db_unchecked(r.granted_scopes), 210 210 granted_at: r.granted_at, 211 211 is_active: r.is_active, 212 212 }) ··· 243 243 .map(|r| DelegatedAccountInfo { 244 244 did: r.did.into(), 245 245 handle: r.handle.into(), 246 - granted_scopes: r.granted_scopes, 246 + granted_scopes: DbScope::from_db_unchecked(r.granted_scopes), 247 247 granted_at: r.granted_at, 248 248 }) 249 249 .collect()) ··· 280 280 .map(|r| ControllerInfo { 281 281 did: r.did.into(), 282 282 handle: r.handle.into(), 283 - granted_scopes: r.granted_scopes, 283 + granted_scopes: DbScope::from_db_unchecked(r.granted_scopes), 284 284 granted_at: r.granted_at, 285 285 is_active: r.is_active, 286 286 })
+34 -18
crates/tranquil-db/src/postgres/infra.rs
··· 3 3 use sqlx::PgPool; 4 4 use tranquil_db_traits::{ 5 5 AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest, 6 - InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, 7 - NotificationHistoryRow, QueuedComms, ReservedSigningKey, 6 + InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, 7 + InviteCodeState, InviteCodeUse, NotificationHistoryRow, QueuedComms, ReservedSigningKey, 8 + ValidatedInviteCode, 8 9 }; 9 10 use tranquil_types::{CidLink, Did, Handle}; 10 11 use uuid::Uuid; ··· 182 183 Ok(result) 183 184 } 184 185 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"#, 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"#, 188 192 code 189 193 ) 190 194 .fetch_optional(&self.pool) 191 195 .await 192 - .map_err(map_sqlx_error)?; 196 + .map_err(|e| InviteCodeError::DatabaseError(map_sqlx_error(e)))?; 193 197 194 - Ok(result.unwrap_or(false)) 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 + } 195 204 } 196 205 197 - async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError> { 206 + async fn decrement_invite_code_uses( 207 + &self, 208 + code: &ValidatedInviteCode<'_>, 209 + ) -> Result<(), DbError> { 198 210 sqlx::query!( 199 211 "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 200 - code 212 + code.code() 201 213 ) 202 214 .execute(&self.pool) 203 215 .await ··· 206 218 Ok(()) 207 219 } 208 220 209 - async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError> { 221 + async fn record_invite_code_use( 222 + &self, 223 + code: &ValidatedInviteCode<'_>, 224 + used_by_user: Uuid, 225 + ) -> Result<(), DbError> { 210 226 sqlx::query!( 211 227 "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 212 - code, 228 + code.code(), 213 229 used_by_user 214 230 ) 215 231 .execute(&self.pool) ··· 245 261 .map(|r| InviteCodeInfo { 246 262 code: r.code, 247 263 available_uses: r.available_uses, 248 - disabled: r.disabled.unwrap_or(false), 264 + state: InviteCodeState::from(r.disabled), 249 265 for_account: Some(Did::from(r.for_account)), 250 266 created_at: r.created_at, 251 267 created_by: None, ··· 422 438 .map(|r| InviteCodeInfo { 423 439 code: r.code, 424 440 available_uses: r.available_uses, 425 - disabled: r.disabled.unwrap_or(false), 441 + state: InviteCodeState::from(r.disabled), 426 442 for_account: Some(Did::from(r.for_account)), 427 443 created_at: r.created_at, 428 444 created_by: Some(Did::from(r.created_by)), ··· 445 461 Ok(result.map(|r| InviteCodeInfo { 446 462 code: r.code, 447 463 available_uses: r.available_uses, 448 - disabled: r.disabled.unwrap_or(false), 464 + state: InviteCodeState::from(r.disabled), 449 465 for_account: Some(Did::from(r.for_account)), 450 466 created_at: r.created_at, 451 467 created_by: Some(Did::from(r.created_by)), ··· 476 492 InviteCodeInfo { 477 493 code: r.code, 478 494 available_uses: r.available_uses, 479 - disabled: r.disabled.unwrap_or(false), 495 + state: InviteCodeState::from(r.disabled), 480 496 for_account: Some(Did::from(r.for_account)), 481 497 created_at: r.created_at, 482 498 created_by: Some(Did::from(r.created_by)), ··· 841 857 r#" 842 858 SELECT 843 859 created_at, 844 - channel as "channel: String", 845 - comms_type as "comms_type: String", 846 - status as "status: String", 860 + channel as "channel: CommsChannel", 861 + comms_type as "comms_type: CommsType", 862 + status as "status: CommsStatus", 847 863 subject, 848 864 body 849 865 FROM comms_queue
+146 -107
crates/tranquil-db/src/postgres/repo.rs
··· 2 2 use chrono::{DateTime, Utc}; 3 3 use sqlx::PgPool; 4 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, 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 9 }; 10 10 use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey}; 11 11 use uuid::Uuid; ··· 21 21 seq: i64, 22 22 did: String, 23 23 created_at: DateTime<Utc>, 24 - event_type: String, 24 + event_type: RepoEventType, 25 25 commit_cid: Option<String>, 26 26 prev_cid: Option<String>, 27 27 prev_data_cid: Option<String>, ··· 627 627 Ok(rows.into_iter().map(|(cid,)| cid).collect()) 628 628 } 629 629 630 - async fn insert_commit_event(&self, data: &CommitEventData) -> Result<i64, DbError> { 630 + async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError> { 631 631 let seq = sqlx::query_scalar!( 632 632 r#" 633 633 INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev) ··· 635 635 RETURNING seq 636 636 "#, 637 637 data.did.as_str(), 638 - data.event_type, 638 + data.event_type.as_str(), 639 639 data.commit_cid.as_ref().map(|c| c.as_str()), 640 640 data.prev_cid.as_ref().map(|c| c.as_str()), 641 641 data.ops, ··· 648 648 .await 649 649 .map_err(map_sqlx_error)?; 650 650 651 - Ok(seq) 651 + Ok(seq.into()) 652 652 } 653 653 654 654 async fn insert_identity_event( 655 655 &self, 656 656 did: &Did, 657 657 handle: Option<&Handle>, 658 - ) -> Result<i64, DbError> { 658 + ) -> Result<SequenceNumber, DbError> { 659 659 let handle_str = handle.map(|h| h.as_str()); 660 660 let seq = sqlx::query_scalar!( 661 661 r#" ··· 675 675 .await 676 676 .map_err(map_sqlx_error)?; 677 677 678 - Ok(seq) 678 + Ok(seq.into()) 679 679 } 680 680 681 681 async fn insert_account_event( 682 682 &self, 683 683 did: &Did, 684 - active: bool, 685 - status: Option<&str>, 686 - ) -> Result<i64, DbError> { 684 + status: AccountStatus, 685 + ) -> Result<SequenceNumber, DbError> { 686 + let active = status.is_active(); 687 + let status_str = status.for_firehose(); 687 688 let seq = sqlx::query_scalar!( 688 689 r#" 689 690 INSERT INTO repo_seq (did, event_type, active, status) ··· 692 693 "#, 693 694 did.as_str(), 694 695 active, 695 - status 696 + status_str 696 697 ) 697 698 .fetch_one(&self.pool) 698 699 .await ··· 703 704 .await 704 705 .map_err(map_sqlx_error)?; 705 706 706 - Ok(seq) 707 + Ok(seq.into()) 707 708 } 708 709 709 710 async fn insert_sync_event( ··· 711 712 did: &Did, 712 713 commit_cid: &CidLink, 713 714 rev: Option<&str>, 714 - ) -> Result<i64, DbError> { 715 + ) -> Result<SequenceNumber, DbError> { 715 716 let seq = sqlx::query_scalar!( 716 717 r#" 717 718 INSERT INTO repo_seq (did, event_type, commit_cid, rev) ··· 731 732 .await 732 733 .map_err(map_sqlx_error)?; 733 734 734 - Ok(seq) 735 + Ok(seq.into()) 735 736 } 736 737 737 738 async fn insert_genesis_commit_event( ··· 740 741 commit_cid: &CidLink, 741 742 mst_root_cid: &CidLink, 742 743 rev: &str, 743 - ) -> Result<i64, DbError> { 744 + ) -> Result<SequenceNumber, DbError> { 744 745 let ops = serde_json::json!([]); 745 746 let blobs: Vec<String> = vec![]; 746 747 let blocks_cids: Vec<String> = vec![mst_root_cid.to_string(), commit_cid.to_string()]; ··· 769 770 .await 770 771 .map_err(map_sqlx_error)?; 771 772 772 - Ok(seq) 773 + Ok(seq.into()) 773 774 } 774 775 775 776 async fn update_seq_blocks_cids( 776 777 &self, 777 - seq: i64, 778 + seq: SequenceNumber, 778 779 blocks_cids: &[String], 779 780 ) -> Result<(), DbError> { 780 781 sqlx::query!( 781 782 "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2", 782 783 blocks_cids, 783 - seq 784 + seq.as_i64() 784 785 ) 785 786 .execute(&self.pool) 786 787 .await ··· 789 790 Ok(()) 790 791 } 791 792 792 - async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError> { 793 + async fn delete_sequences_except( 794 + &self, 795 + did: &Did, 796 + keep_seq: SequenceNumber, 797 + ) -> Result<(), DbError> { 793 798 sqlx::query!( 794 799 "DELETE FROM repo_seq WHERE did = $1 AND seq != $2", 795 800 did.as_str(), 796 - keep_seq 801 + keep_seq.as_i64() 797 802 ) 798 803 .execute(&self.pool) 799 804 .await ··· 802 807 Ok(()) 803 808 } 804 809 805 - async fn get_max_seq(&self) -> Result<i64, DbError> { 810 + async fn get_max_seq(&self) -> Result<SequenceNumber, DbError> { 806 811 let seq = sqlx::query_scalar!(r#"SELECT COALESCE(MAX(seq), 0) as "max!" FROM repo_seq"#) 807 812 .fetch_one(&self.pool) 808 813 .await 809 814 .map_err(map_sqlx_error)?; 810 815 811 - Ok(seq) 816 + Ok(seq.into()) 812 817 } 813 818 814 - async fn get_min_seq_since(&self, since: DateTime<Utc>) -> Result<Option<i64>, DbError> { 819 + async fn get_min_seq_since( 820 + &self, 821 + since: DateTime<Utc>, 822 + ) -> Result<Option<SequenceNumber>, DbError> { 815 823 let seq = sqlx::query_scalar!( 816 824 "SELECT MIN(seq) FROM repo_seq WHERE created_at >= $1", 817 825 since ··· 820 828 .await 821 829 .map_err(map_sqlx_error)?; 822 830 823 - Ok(seq) 831 + Ok(seq.map(SequenceNumber::from)) 824 832 } 825 833 826 834 async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError> { ··· 846 854 847 855 async fn get_events_since_seq( 848 856 &self, 849 - since_seq: i64, 857 + since_seq: SequenceNumber, 850 858 limit: Option<i64>, 851 859 ) -> 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, 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 + } 867 882 }; 868 883 match limit { 869 884 Some(lim) => { 870 885 let rows = sqlx::query_as!( 871 886 SequencedEventRow, 872 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 887 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 873 888 ops, blobs, blocks_cids, handle, active, status, rev 874 889 FROM repo_seq 875 890 WHERE seq > $1 876 891 ORDER BY seq ASC 877 892 LIMIT $2"#, 878 - since_seq, 893 + since_seq.as_i64(), 879 894 lim 880 895 ) 881 896 .fetch_all(&self.pool) ··· 886 901 None => { 887 902 let rows = sqlx::query_as!( 888 903 SequencedEventRow, 889 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 904 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 890 905 ops, blobs, blocks_cids, handle, active, status, rev 891 906 FROM repo_seq 892 907 WHERE seq > $1 893 908 ORDER BY seq ASC"#, 894 - since_seq 909 + since_seq.as_i64() 895 910 ) 896 911 .fetch_all(&self.pool) 897 912 .await ··· 903 918 904 919 async fn get_events_in_seq_range( 905 920 &self, 906 - start_seq: i64, 907 - end_seq: i64, 921 + start_seq: SequenceNumber, 922 + end_seq: SequenceNumber, 908 923 ) -> Result<Vec<SequencedEvent>, DbError> { 909 924 let rows = sqlx::query!( 910 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 925 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 911 926 ops, blobs, blocks_cids, handle, active, status, rev 912 927 FROM repo_seq 913 928 WHERE seq > $1 AND seq < $2 914 929 ORDER BY seq ASC"#, 915 - start_seq, 916 - end_seq 930 + start_seq.as_i64(), 931 + end_seq.as_i64() 917 932 ) 918 933 .fetch_all(&self.pool) 919 934 .await 920 935 .map_err(map_sqlx_error)?; 921 936 Ok(rows 922 937 .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 + .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 + } 938 960 }) 939 961 .collect()) 940 962 } 941 963 942 - async fn get_event_by_seq(&self, seq: i64) -> Result<Option<SequencedEvent>, DbError> { 964 + async fn get_event_by_seq( 965 + &self, 966 + seq: SequenceNumber, 967 + ) -> Result<Option<SequencedEvent>, DbError> { 943 968 let row = sqlx::query!( 944 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 969 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 945 970 ops, blobs, blocks_cids, handle, active, status, rev 946 971 FROM repo_seq 947 972 WHERE seq = $1"#, 948 - seq 973 + seq.as_i64() 949 974 ) 950 975 .fetch_optional(&self.pool) 951 976 .await 952 977 .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, 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 + } 968 1000 })) 969 1001 } 970 1002 971 1003 async fn get_events_since_cursor( 972 1004 &self, 973 - cursor: i64, 1005 + cursor: SequenceNumber, 974 1006 limit: i64, 975 1007 ) -> Result<Vec<SequencedEvent>, DbError> { 976 1008 let rows = sqlx::query!( 977 - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, 1009 + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, 978 1010 ops, blobs, blocks_cids, handle, active, status, rev 979 1011 FROM repo_seq 980 1012 WHERE seq > $1 981 1013 ORDER BY seq ASC 982 1014 LIMIT $2"#, 983 - cursor, 1015 + cursor.as_i64(), 984 1016 limit 985 1017 ) 986 1018 .fetch_all(&self.pool) ··· 988 1020 .map_err(map_sqlx_error)?; 989 1021 Ok(rows 990 1022 .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, 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 + } 1006 1045 }) 1007 1046 .collect()) 1008 1047 } ··· 1079 1118 Ok(cid.map(CidLink::from)) 1080 1119 } 1081 1120 1082 - async fn notify_update(&self, seq: i64) -> Result<(), DbError> { 1083 - sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) 1121 + async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError> { 1122 + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq.as_i64())) 1084 1123 .execute(&self.pool) 1085 1124 .await 1086 1125 .map_err(map_sqlx_error)?; ··· 1329 1368 "#, 1330 1369 ) 1331 1370 .bind(&event.did) 1332 - .bind(&event.event_type) 1371 + .bind(event.event_type.as_str()) 1333 1372 .bind(&event.commit_cid) 1334 1373 .bind(&event.prev_cid) 1335 1374 .bind(&event.ops) ··· 1375 1414 Ok(rows 1376 1415 .into_iter() 1377 1416 .map(|r| BrokenGenesisCommit { 1378 - seq: r.seq, 1417 + seq: r.seq.into(), 1379 1418 did: Did::from(r.did), 1380 1419 commit_cid: r.commit_cid.map(CidLink::from), 1381 1420 })
+28 -28
crates/tranquil-db/src/postgres/session.rs
··· 2 2 use chrono::{DateTime, Utc}; 3 3 use sqlx::PgPool; 4 4 use tranquil_db_traits::{ 5 - AppPasswordCreate, AppPasswordRecord, DbError, RefreshSessionResult, SessionForRefresh, 6 - SessionListItem, SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, 7 - SessionTokenCreate, 5 + AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, DbError, LoginType, 6 + RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, 7 + SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, 8 8 }; 9 9 use tranquil_types::Did; 10 10 use uuid::Uuid; ··· 23 23 24 24 #[async_trait] 25 25 impl SessionRepository for PostgresSessionRepository { 26 - async fn create_session(&self, data: &SessionTokenCreate) -> Result<i32, DbError> { 26 + async fn create_session(&self, data: &SessionTokenCreate) -> Result<SessionId, DbError> { 27 27 let row = sqlx::query!( 28 28 r#" 29 29 INSERT INTO session_tokens ··· 37 37 data.refresh_jti, 38 38 data.access_expires_at, 39 39 data.refresh_expires_at, 40 - data.legacy_login, 40 + bool::from(data.login_type), 41 41 data.mfa_verified, 42 42 data.scope, 43 43 data.controller_did.as_ref().map(|d| d.as_str()), ··· 47 47 .await 48 48 .map_err(map_sqlx_error)?; 49 49 50 - Ok(row.id) 50 + Ok(SessionId::new(row.id)) 51 51 } 52 52 53 53 async fn get_session_by_access_jti( ··· 69 69 .map_err(map_sqlx_error)?; 70 70 71 71 Ok(row.map(|r| SessionToken { 72 - id: r.id, 72 + id: SessionId::new(r.id), 73 73 did: Did::from(r.did), 74 74 access_jti: r.access_jti, 75 75 refresh_jti: r.refresh_jti, 76 76 access_expires_at: r.access_expires_at, 77 77 refresh_expires_at: r.refresh_expires_at, 78 - legacy_login: r.legacy_login, 78 + login_type: LoginType::from(r.legacy_login), 79 79 mfa_verified: r.mfa_verified, 80 80 scope: r.scope, 81 81 controller_did: r.controller_did.map(Did::from), ··· 104 104 .map_err(map_sqlx_error)?; 105 105 106 106 Ok(row.map(|r| SessionForRefresh { 107 - id: r.id, 107 + id: SessionId::new(r.id), 108 108 did: Did::from(r.did), 109 109 scope: r.scope, 110 110 controller_did: r.controller_did.map(Did::from), ··· 115 115 116 116 async fn update_session_tokens( 117 117 &self, 118 - session_id: i32, 118 + session_id: SessionId, 119 119 new_access_jti: &str, 120 120 new_refresh_jti: &str, 121 121 new_access_expires_at: DateTime<Utc>, ··· 132 132 new_refresh_jti, 133 133 new_access_expires_at, 134 134 new_refresh_expires_at, 135 - session_id 135 + session_id.as_i32() 136 136 ) 137 137 .execute(&self.pool) 138 138 .await ··· 153 153 Ok(result.rows_affected()) 154 154 } 155 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) 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 158 .execute(&self.pool) 159 159 .await 160 160 .map_err(map_sqlx_error)?; ··· 205 205 Ok(rows 206 206 .into_iter() 207 207 .map(|r| SessionListItem { 208 - id: r.id, 208 + id: SessionId::new(r.id), 209 209 access_jti: r.access_jti, 210 210 created_at: r.created_at, 211 211 refresh_expires_at: r.refresh_expires_at, ··· 215 215 216 216 async fn get_session_access_jti_by_id( 217 217 &self, 218 - session_id: i32, 218 + session_id: SessionId, 219 219 did: &Did, 220 220 ) -> Result<Option<String>, DbError> { 221 221 let row = sqlx::query_scalar!( 222 222 "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", 223 - session_id, 223 + session_id.as_i32(), 224 224 did.as_str() 225 225 ) 226 226 .fetch_optional(&self.pool) ··· 264 264 Ok(rows) 265 265 } 266 266 267 - async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<i32>, DbError> { 267 + async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<SessionId>, DbError> { 268 268 let row = sqlx::query_scalar!( 269 269 "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", 270 270 refresh_jti ··· 273 273 .await 274 274 .map_err(map_sqlx_error)?; 275 275 276 - Ok(row) 276 + Ok(row.map(SessionId::new)) 277 277 } 278 278 279 279 async fn mark_refresh_token_used( 280 280 &self, 281 281 refresh_jti: &str, 282 - session_id: i32, 282 + session_id: SessionId, 283 283 ) -> Result<bool, DbError> { 284 284 let result = sqlx::query!( 285 285 r#" ··· 288 288 ON CONFLICT (refresh_jti) DO NOTHING 289 289 "#, 290 290 refresh_jti, 291 - session_id 291 + session_id.as_i32() 292 292 ) 293 293 .execute(&self.pool) 294 294 .await ··· 319 319 name: r.name, 320 320 password_hash: r.password_hash, 321 321 created_at: r.created_at, 322 - privileged: r.privileged, 322 + privilege: AppPasswordPrivilege::from(r.privileged), 323 323 scopes: r.scopes, 324 324 created_by_controller_did: r.created_by_controller_did.map(Did::from), 325 325 }) ··· 352 352 name: r.name, 353 353 password_hash: r.password_hash, 354 354 created_at: r.created_at, 355 - privileged: r.privileged, 355 + privilege: AppPasswordPrivilege::from(r.privileged), 356 356 scopes: r.scopes, 357 357 created_by_controller_did: r.created_by_controller_did.map(Did::from), 358 358 }) ··· 383 383 name: r.name, 384 384 password_hash: r.password_hash, 385 385 created_at: r.created_at, 386 - privileged: r.privileged, 386 + privilege: AppPasswordPrivilege::from(r.privileged), 387 387 scopes: r.scopes, 388 388 created_by_controller_did: r.created_by_controller_did.map(Did::from), 389 389 })) ··· 399 399 data.user_id, 400 400 data.name, 401 401 data.password_hash, 402 - data.privileged, 402 + bool::from(data.privilege), 403 403 data.scopes, 404 404 data.created_by_controller_did.as_ref().map(|d| d.as_str()) 405 405 ) ··· 480 480 .map_err(map_sqlx_error)?; 481 481 482 482 Ok(row.map(|r| SessionMfaStatus { 483 - legacy_login: r.legacy_login, 483 + login_type: LoginType::from(r.legacy_login), 484 484 mfa_verified: r.mfa_verified, 485 485 last_reauth_at: r.last_reauth_at, 486 486 })) ··· 535 535 let result = sqlx::query!( 536 536 "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", 537 537 data.old_refresh_jti, 538 - data.session_id 538 + data.session_id.as_i32() 539 539 ) 540 540 .execute(&mut *tx) 541 541 .await 542 542 .map_err(map_sqlx_error)?; 543 543 544 544 if result.rows_affected() == 0 { 545 - let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", data.session_id) 545 + let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", data.session_id.as_i32()) 546 546 .execute(&mut *tx) 547 547 .await; 548 548 tx.commit().await.map_err(map_sqlx_error)?; ··· 555 555 data.new_refresh_jti, 556 556 data.new_access_expires_at, 557 557 data.new_refresh_expires_at, 558 - data.session_id 558 + data.session_id.as_i32() 559 559 ) 560 560 .execute(&mut *tx) 561 561 .await
+31 -26
crates/tranquil-db/src/postgres/sso.rs
··· 2 2 use chrono::Utc; 3 3 use sqlx::PgPool; 4 4 use tranquil_db_traits::{ 5 - DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, 5 + DbError, ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction, 6 + SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, 6 7 }; 7 8 use tranquil_types::Did; 8 9 use uuid::Uuid; ··· 71 72 id: r.id, 72 73 did: Did::new_unchecked(&r.did), 73 74 provider: r.provider, 74 - provider_user_id: r.provider_user_id, 75 - provider_username: r.provider_username, 76 - provider_email: r.provider_email, 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), 77 78 created_at: r.created_at, 78 79 updated_at: r.updated_at, 79 80 last_login_at: r.last_login_at, ··· 104 105 id: r.id, 105 106 did: Did::new_unchecked(&r.did), 106 107 provider: r.provider, 107 - provider_user_id: r.provider_user_id, 108 - provider_username: r.provider_username, 109 - provider_email: r.provider_email, 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), 110 111 created_at: r.created_at, 111 112 updated_at: r.updated_at, 112 113 last_login_at: r.last_login_at, ··· 161 162 state: &str, 162 163 request_uri: &str, 163 164 provider: SsoProviderType, 164 - action: &str, 165 + action: SsoAction, 165 166 nonce: Option<&str>, 166 167 code_verifier: Option<&str>, 167 168 did: Option<&Did>, ··· 174 175 state, 175 176 request_uri, 176 177 provider as SsoProviderType, 177 - action, 178 + action.as_str(), 178 179 nonce, 179 180 code_verifier, 180 181 did.map(|d| d.as_str()), ··· 200 201 .await 201 202 .map_err(map_sqlx_error)?; 202 203 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 - })) 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() 214 219 } 215 220 216 221 async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError> { ··· 280 285 token: r.token, 281 286 request_uri: r.request_uri, 282 287 provider: r.provider, 283 - provider_user_id: r.provider_user_id, 284 - provider_username: r.provider_username, 285 - provider_email: r.provider_email, 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), 286 291 provider_email_verified: r.provider_email_verified, 287 292 created_at: r.created_at, 288 293 expires_at: r.expires_at, ··· 311 316 token: r.token, 312 317 request_uri: r.request_uri, 313 318 provider: r.provider, 314 - provider_user_id: r.provider_user_id, 315 - provider_username: r.provider_username, 316 - provider_email: r.provider_email, 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), 317 322 provider_email_verified: r.provider_email_verified, 318 323 created_at: r.created_at, 319 324 expires_at: r.expires_at,
+65 -45
crates/tranquil-db/src/postgres/user.rs
··· 5 5 use uuid::Uuid; 6 6 7 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, 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 17 }; 18 18 19 19 pub struct PostgresUserRepository { ··· 280 280 password_hash: r.password_hash, 281 281 deactivated_at: r.deactivated_at, 282 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, 283 + channel_verification: ChannelVerificationStatus::new( 284 + r.email_verified, 285 + r.discord_verified, 286 + r.telegram_verified, 287 + r.signal_verified, 288 + ), 287 289 })) 288 290 } 289 291 ··· 308 310 309 311 async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> { 310 312 let row = sqlx::query!( 311 - r#"SELECT email, handle, preferred_comms_channel::text as "preferred_channel!", preferred_locale 313 + r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale 312 314 FROM users WHERE id = $1"#, 313 315 user_id 314 316 ) ··· 601 603 let row = sqlx::query!( 602 604 r#"SELECT 603 605 email, 604 - preferred_comms_channel::text as "preferred_channel!", 606 + preferred_comms_channel as "preferred_channel!: CommsChannel", 605 607 discord_id, 606 608 discord_verified, 607 609 telegram_username, ··· 647 649 async fn update_preferred_comms_channel( 648 650 &self, 649 651 did: &Did, 650 - channel: &str, 652 + channel: CommsChannel, 651 653 ) -> Result<(), DbError> { 652 - sqlx::query( 653 - "UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2", 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() 654 658 ) 655 - .bind(channel) 656 - .bind(did.as_str()) 657 659 .execute(&self.pool) 658 660 .await 659 661 .map_err(map_sqlx_error)?; ··· 709 711 id: r.id, 710 712 handle: Handle::from(r.handle), 711 713 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, 714 + channel_verification: ChannelVerificationStatus::new( 715 + r.email_verified, 716 + r.discord_verified, 717 + r.telegram_verified, 718 + r.signal_verified, 719 + ), 716 720 })) 717 721 } 718 722 ··· 1065 1069 })) 1066 1070 } 1067 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 + 1068 1078 async fn upsert_totp_secret( 1069 1079 &self, 1070 1080 did: &Did, ··· 1300 1310 preferred_comms_channel as "preferred_comms_channel!: CommsChannel", 1301 1311 deactivated_at, takedown_ref, 1302 1312 email_verified, discord_verified, telegram_verified, signal_verified, 1303 - account_type::text as "account_type!" 1313 + account_type as "account_type!: AccountType" 1304 1314 FROM users 1305 1315 WHERE handle = $1 OR email = $1 1306 1316 "#, ··· 1320 1330 preferred_comms_channel: row.preferred_comms_channel, 1321 1331 deactivated_at: row.deactivated_at, 1322 1332 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, 1333 + channel_verification: ChannelVerificationStatus::new( 1334 + row.email_verified, 1335 + row.discord_verified, 1336 + row.telegram_verified, 1337 + row.signal_verified, 1338 + ), 1327 1339 account_type: row.account_type, 1328 1340 }) 1329 1341 }) ··· 1348 1360 id: row.id, 1349 1361 two_factor_enabled: row.two_factor_enabled, 1350 1362 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, 1363 + channel_verification: ChannelVerificationStatus::new( 1364 + row.email_verified, 1365 + row.discord_verified, 1366 + row.telegram_verified, 1367 + row.signal_verified, 1368 + ), 1355 1369 }) 1356 1370 }) 1357 1371 } ··· 1376 1390 opt.map(|row| UserSessionInfo { 1377 1391 handle: Handle::from(row.handle), 1378 1392 email: row.email, 1379 - email_verified: row.email_verified, 1380 1393 is_admin: row.is_admin, 1381 1394 deactivated_at: row.deactivated_at, 1382 1395 takedown_ref: row.takedown_ref, 1383 1396 preferred_locale: row.preferred_locale, 1384 1397 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, 1398 + channel_verification: ChannelVerificationStatus::new( 1399 + row.email_verified, 1400 + row.discord_verified, 1401 + row.telegram_verified, 1402 + row.signal_verified, 1403 + ), 1388 1404 migrated_to_pds: row.migrated_to_pds, 1389 1405 migrated_at: row.migrated_at, 1390 1406 }) ··· 1469 1485 email: row.email, 1470 1486 deactivated_at: row.deactivated_at, 1471 1487 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, 1488 + channel_verification: ChannelVerificationStatus::new( 1489 + row.email_verified, 1490 + row.discord_verified, 1491 + row.telegram_verified, 1492 + row.signal_verified, 1493 + ), 1476 1494 allow_legacy_login: row.allow_legacy_login, 1477 1495 migrated_to_pds: row.migrated_to_pds, 1478 1496 preferred_comms_channel: row.preferred_comms_channel, ··· 1543 1561 discord_id: row.discord_id, 1544 1562 telegram_username: row.telegram_username, 1545 1563 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, 1564 + channel_verification: ChannelVerificationStatus::new( 1565 + row.email_verified, 1566 + row.discord_verified, 1567 + row.telegram_verified, 1568 + row.signal_verified, 1569 + ), 1550 1570 }) 1551 1571 }) 1552 1572 }
+8 -18
crates/tranquil-pds/src/api/admin/status.rs
··· 207 207 } 208 208 if let Some(takedown) = &input.takedown { 209 209 let status = if takedown.applied { 210 - Some("takendown") 210 + tranquil_db_traits::AccountStatus::Takendown 211 211 } else { 212 - None 212 + tranquil_db_traits::AccountStatus::Active 213 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 214 + if let Err(e) = 215 + crate::api::repo::record::sequence_account_event(&state, &did, status).await 221 216 { 222 217 warn!("Failed to sequence account event for takedown: {}", e); 223 218 } 224 219 } 225 220 if let Some(deactivated) = &input.deactivated { 226 221 let status = if deactivated.applied { 227 - Some("deactivated") 222 + tranquil_db_traits::AccountStatus::Deactivated 228 223 } else { 229 - None 224 + tranquil_db_traits::AccountStatus::Active 230 225 }; 231 - if let Err(e) = crate::api::repo::record::sequence_account_event( 232 - &state, 233 - &did, 234 - !deactivated.applied, 235 - status, 236 - ) 237 - .await 226 + if let Err(e) = 227 + crate::api::repo::record::sequence_account_event(&state, &did, status).await 238 228 { 239 229 warn!("Failed to sequence account event for deactivation: {}", e); 240 230 }
+5
crates/tranquil-pds/src/api/repo/record/mod.rs
··· 1 1 pub mod batch; 2 2 pub mod delete; 3 + pub mod pagination; 3 4 pub mod read; 4 5 pub mod utils; 5 6 pub mod validation; 7 + pub mod validation_mode; 6 8 pub mod write; 7 9 10 + pub use pagination::PaginationDirection; 11 + pub use validation_mode::ValidationMode; 12 + 8 13 pub use batch::apply_writes; 9 14 pub use delete::{DeleteRecordInput, delete_record, delete_record_internal}; 10 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 8 use k256::ecdsa::SigningKey; 9 9 use serde_json::{Value, json}; 10 10 use std::str::FromStr; 11 + use tranquil_db_traits::SequenceNumber; 11 12 use uuid::Uuid; 12 13 13 14 pub fn extract_blob_cids(record: &Value) -> Vec<String> { ··· 139 140 ) -> Result<CommitResult, String> { 140 141 use tranquil_db_traits::{ 141 142 ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert, 143 + RepoEventType, 142 144 }; 143 145 144 146 let CommitParams { ··· 263 265 264 266 let commit_event = CommitEventData { 265 267 did: did.clone(), 266 - event_type: "commit".to_string(), 268 + event_type: RepoEventType::Commit, 267 269 commit_cid: Some(crate::types::CidLink::new_unchecked( 268 270 new_root_cid.to_string(), 269 271 )), ··· 417 419 state: &AppState, 418 420 did: &Did, 419 421 handle: Option<&Handle>, 420 - ) -> Result<i64, String> { 422 + ) -> Result<SequenceNumber, String> { 421 423 state 422 424 .repo_repo 423 425 .insert_identity_event(did, handle) ··· 427 429 pub async fn sequence_account_event( 428 430 state: &AppState, 429 431 did: &Did, 430 - active: bool, 431 - status: Option<&str>, 432 - ) -> Result<i64, String> { 432 + status: tranquil_db_traits::AccountStatus, 433 + ) -> Result<SequenceNumber, String> { 433 434 state 434 435 .repo_repo 435 - .insert_account_event(did, active, status) 436 + .insert_account_event(did, status) 436 437 .await 437 438 .map_err(|e| format!("DB Error (account event): {}", e)) 438 439 } ··· 441 442 did: &Did, 442 443 commit_cid: &str, 443 444 rev: Option<&str>, 444 - ) -> Result<i64, String> { 445 + ) -> Result<SequenceNumber, String> { 445 446 let cid_link = crate::types::CidLink::new_unchecked(commit_cid); 446 447 state 447 448 .repo_repo ··· 456 457 commit_cid: &Cid, 457 458 mst_root_cid: &Cid, 458 459 rev: &str, 459 - ) -> Result<i64, String> { 460 + ) -> Result<SequenceNumber, String> { 460 461 let commit_cid_link = crate::types::CidLink::new_unchecked(commit_cid.to_string()); 461 462 let mst_root_cid_link = crate::types::CidLink::new_unchecked(mst_root_cid.to_string()); 462 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 278 &[("hostname", hostname), ("handle", &prefs.handle)], 279 279 ); 280 280 let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]); 281 - let channel = channel_from_str(&prefs.preferred_channel); 281 + let channel = prefs.preferred_channel; 282 282 infra_repo 283 283 .enqueue_comms( 284 284 Some(user_id), ··· 309 309 &[("handle", &prefs.handle), ("code", code)], 310 310 ); 311 311 let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]); 312 - let channel = channel_from_str(&prefs.preferred_channel); 312 + let channel = prefs.preferred_channel; 313 313 infra_repo 314 314 .enqueue_comms( 315 315 Some(user_id), ··· 422 422 &[("handle", &prefs.handle), ("code", code)], 423 423 ); 424 424 let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]); 425 - let channel = channel_from_str(&prefs.preferred_channel); 425 + let channel = prefs.preferred_channel; 426 426 infra_repo 427 427 .enqueue_comms( 428 428 Some(user_id), ··· 453 453 &[("handle", &prefs.handle), ("token", token)], 454 454 ); 455 455 let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]); 456 - let channel = channel_from_str(&prefs.preferred_channel); 456 + let channel = prefs.preferred_channel; 457 457 infra_repo 458 458 .enqueue_comms( 459 459 Some(user_id), ··· 484 484 &[("handle", &prefs.handle), ("url", recovery_url)], 485 485 ); 486 486 let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]); 487 - let channel = channel_from_str(&prefs.preferred_channel); 487 + let channel = prefs.preferred_channel; 488 488 infra_repo 489 489 .enqueue_comms( 490 490 Some(user_id), ··· 614 614 &[("handle", &prefs.handle), ("code", code)], 615 615 ); 616 616 let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]); 617 - let channel = channel_from_str(&prefs.preferred_channel); 617 + let channel = prefs.preferred_channel; 618 618 infra_repo 619 619 .enqueue_comms( 620 620 Some(user_id),
+5 -29
crates/tranquil-pds/src/delegation/scopes.rs
··· 1 1 use std::collections::HashSet; 2 2 3 + pub use tranquil_db_traits::{DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError}; 4 + 3 5 pub struct ScopePreset { 4 6 pub name: &'static str, 5 7 pub label: &'static str, ··· 107 109 } 108 110 } 109 111 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)) 112 + pub fn validate_delegation_scopes(scopes: &str) -> Result<(), InvalidDelegationScopeError> { 113 + ValidatedDelegationScope::new(scopes)?; 114 + Ok(()) 139 115 } 140 116 141 117 #[cfg(test)]
+2 -1
crates/tranquil-pds/src/lib.rs
··· 2 2 pub mod appview; 3 3 pub mod auth; 4 4 pub mod cache; 5 + pub mod cid_types; 5 6 pub mod circuit_breaker; 6 7 pub mod comms; 7 8 pub mod config; ··· 35 36 use http::StatusCode; 36 37 use serde_json::json; 37 38 use state::AppState; 38 - pub use sync::util::AccountStatus; 39 39 use tower::ServiceBuilder; 40 40 use tower_http::cors::{Any, CorsLayer}; 41 + pub use tranquil_db_traits::AccountStatus; 41 42 pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; 42 43 43 44 pub fn app(state: AppState) -> Router {
+5 -5
crates/tranquil-pds/src/scheduled.rs
··· 9 9 use tokio::time::interval; 10 10 use tracing::{debug, error, info, warn}; 11 11 use tranquil_db_traits::{ 12 - BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SsoRepository, 13 - UserRepository, 12 + BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SequenceNumber, 13 + SsoRepository, UserRepository, 14 14 }; 15 15 use tranquil_types::{AtUri, CidLink, Did}; 16 16 ··· 22 22 repo_repo: &dyn RepoRepository, 23 23 block_store: &PostgresBlockStore, 24 24 row: BrokenGenesisCommit, 25 - ) -> Result<(Did, i64), (i64, &'static str)> { 25 + ) -> Result<(Did, SequenceNumber), (SequenceNumber, &'static str)> { 26 26 let commit_cid_str = row.commit_cid.ok_or((row.seq, "missing commit_cid"))?; 27 27 let commit_cid = Cid::from_str(&commit_cid_str).map_err(|_| (row.seq, "invalid CID"))?; 28 28 let block = block_store ··· 73 73 74 74 let (success, failed) = results.iter().fold((0, 0), |(s, f), r| match r { 75 75 Ok((did, seq)) => { 76 - info!(seq = seq, did = %did, "Fixed genesis commit blocks_cids"); 76 + info!(seq = seq.as_i64(), did = %did, "Fixed genesis commit blocks_cids"); 77 77 (s + 1, f) 78 78 } 79 79 Err((seq, reason)) => { 80 80 warn!( 81 - seq = seq, 81 + seq = seq.as_i64(), 82 82 reason = reason, 83 83 "Failed to process genesis commit" 84 84 );
+4 -3
crates/tranquil-pds/src/sync/commit.rs
··· 1 1 use crate::api::error::ApiError; 2 2 use crate::state::AppState; 3 - use crate::sync::util::{AccountStatus, assert_repo_availability, get_account_with_status}; 3 + use crate::sync::util::{assert_repo_availability, get_account_with_status}; 4 4 use axum::{ 5 5 Json, 6 6 extract::{Query, State}, ··· 13 13 use serde::{Deserialize, Serialize}; 14 14 use std::str::FromStr; 15 15 use tracing::error; 16 + use tranquil_db_traits::AccountStatus; 16 17 use tranquil_types::Did; 17 18 18 19 async fn get_rev_from_commit(state: &AppState, cid_str: &str) -> Option<String> { ··· 130 131 head: cid_str, 131 132 rev, 132 133 active: status.is_active(), 133 - status: status.as_str().map(String::from), 134 + status: status.for_firehose().map(String::from), 134 135 }); 135 136 } 136 137 let next_cursor = if has_more { ··· 212 213 Json(GetRepoStatusOutput { 213 214 did: account.did, 214 215 active: account.status.is_active(), 215 - status: account.status.as_str().map(String::from), 216 + status: account.status.for_firehose().map(String::from), 216 217 rev, 217 218 }), 218 219 )
+10 -7
crates/tranquil-pds/src/sync/frame.rs
··· 2 2 use cid::Cid; 3 3 use serde::{Deserialize, Serialize}; 4 4 use std::str::FromStr; 5 + use tranquil_scopes::RepoAction; 5 6 6 7 #[derive(Debug, Serialize, Deserialize)] 7 8 pub struct FrameHeader { ··· 38 39 39 40 #[derive(Debug, Serialize, Deserialize)] 40 41 pub struct RepoOp { 41 - pub action: String, 42 + pub action: RepoAction, 42 43 pub path: String, 43 44 pub cid: Option<Cid>, 44 45 #[serde(skip_serializing_if = "Option::is_none")] ··· 159 160 serde_json::from_value(self.ops_json).unwrap_or_else(|_| vec![]); 160 161 let ops: Vec<RepoOp> = json_ops 161 162 .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()), 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 + }) 167 170 }) 168 171 .collect(); 169 172 let rev = self.rev.unwrap_or_else(placeholder_rev); ··· 202 205 CommitFrameError::InvalidCommitCid("Missing commit_cid in event".to_string()) 203 206 })?; 204 207 let builder = CommitFrameBuilder::new( 205 - event.seq, 208 + event.seq.as_i64(), 206 209 event.did.to_string(), 207 210 commit_cid.as_str(), 208 211 event.prev_cid.as_ref().map(|c| c.as_str()),
+21 -10
crates/tranquil-pds/src/sync/listener.rs
··· 2 2 use crate::sync::firehose::SequencedEvent; 3 3 use std::sync::atomic::{AtomicI64, Ordering}; 4 4 use tracing::{debug, error, info, warn}; 5 + use tranquil_db_traits::SequenceNumber; 5 6 6 7 static LAST_BROADCAST_SEQ: AtomicI64 = AtomicI64::new(0); 7 8 8 9 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"); 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 + ); 12 20 tokio::spawn(async move { 13 21 info!("Starting sequencer listener background task"); 14 22 loop { ··· 27 35 .await 28 36 .map_err(|e| anyhow::anyhow!("Failed to subscribe to events: {:?}", e))?; 29 37 info!("Connected to database and listening for repo updates"); 30 - let catchup_start = LAST_BROADCAST_SEQ.load(Ordering::SeqCst); 38 + let catchup_start = SequenceNumber::from_raw(LAST_BROADCAST_SEQ.load(Ordering::SeqCst)); 31 39 let events = state 32 40 .repo_repo 33 41 .get_events_since_seq(catchup_start, None) ··· 36 44 if !events.is_empty() { 37 45 info!( 38 46 count = events.len(), 39 - from_seq = catchup_start, 47 + from_seq = catchup_start.as_i64(), 40 48 "Broadcasting catch-up events" 41 49 ); 42 50 events.into_iter().for_each(|event| { 43 51 let seq = event.seq; 44 52 let firehose_event = to_firehose_event(event); 45 53 let _ = state.firehose_tx.send(firehose_event); 46 - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); 54 + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); 47 55 }); 48 56 } 49 57 loop { ··· 63 71 if seq_id > last_seq + 1 { 64 72 let gap_events = state 65 73 .repo_repo 66 - .get_events_in_seq_range(last_seq, seq_id) 74 + .get_events_in_seq_range( 75 + SequenceNumber::from_raw(last_seq), 76 + SequenceNumber::from_raw(seq_id), 77 + ) 67 78 .await 68 79 .unwrap_or_default(); 69 80 if !gap_events.is_empty() { ··· 72 83 let seq = event.seq; 73 84 let firehose_event = to_firehose_event(event); 74 85 let _ = state.firehose_tx.send(firehose_event); 75 - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); 86 + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); 76 87 }); 77 88 } 78 89 } 79 90 let event = state 80 91 .repo_repo 81 - .get_event_by_seq(seq_id) 92 + .get_event_by_seq(SequenceNumber::from_raw(seq_id)) 82 93 .await 83 94 .ok() 84 95 .flatten(); ··· 97 108 warn!(seq = seq_id, error = %e, "Failed to broadcast event (no receivers?)"); 98 109 } 99 110 } 100 - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); 111 + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); 101 112 } else { 102 113 warn!( 103 114 seq = seq_id,
+2 -2
crates/tranquil-pds/src/sync/mod.rs
··· 18 18 pub use deprecated::{get_checkout, get_head}; 19 19 pub use repo::{get_blocks, get_record, get_repo}; 20 20 pub use subscribe_repos::subscribe_repos; 21 + pub use tranquil_db_traits::AccountStatus; 21 22 pub use util::{ 22 - AccountStatus, RepoAccount, RepoAvailabilityError, assert_repo_availability, 23 - get_account_with_status, 23 + RepoAccount, RepoAvailabilityError, assert_repo_availability, get_account_with_status, 24 24 }; 25 25 pub use verify::{CarVerifier, VerifiedCar, VerifyError};
+12 -6
crates/tranquil-pds/src/sync/subscribe_repos.rs
··· 13 13 use std::sync::atomic::{AtomicUsize, Ordering}; 14 14 use tokio::sync::broadcast::error::RecvError; 15 15 use tracing::{error, info, warn}; 16 + use tranquil_db_traits::SequenceNumber; 16 17 17 18 const BACKFILL_BATCH_SIZE: i64 = 1000; 18 19 ··· 69 70 params: SubscribeReposParams, 70 71 ) -> Result<(), ()> { 71 72 let mut rx = state.firehose_tx.subscribe(); 72 - let mut last_seen: i64 = -1; 73 + let mut last_seen = SequenceNumber::UNSET; 73 74 74 75 if let Some(cursor) = params.cursor { 75 - let current_seq = state.repo_repo.get_max_seq().await.unwrap_or(0); 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); 76 82 77 - if cursor > current_seq { 83 + if cursor_seq > current_seq { 78 84 if let Ok(error_bytes) = 79 85 format_error_frame("FutureCursor", Some("Cursor in the future.")) 80 86 { ··· 88 94 89 95 let first_event = state 90 96 .repo_repo 91 - .get_events_since_cursor(cursor, 1) 97 + .get_events_since_cursor(cursor_seq, 1) 92 98 .await 93 99 .ok() 94 100 .and_then(|events| events.into_iter().next()); 95 101 96 - let mut current_cursor = cursor; 102 + let mut current_cursor = cursor_seq; 97 103 98 104 if let Some(ref event) = first_event 99 105 && event.created_at < backfill_time ··· 113 119 .flatten(); 114 120 115 121 if let Some(earliest_seq) = earliest { 116 - current_cursor = earliest_seq - 1; 122 + current_cursor = SequenceNumber::from_raw(earliest_seq.as_i64() - 1); 117 123 } 118 124 } 119 125
+18 -102
crates/tranquil-pds/src/sync/util.rs
··· 11 11 use iroh_car::{CarHeader, CarWriter}; 12 12 use jacquard_repo::commit::Commit; 13 13 use jacquard_repo::storage::BlockStore; 14 - use serde::Serialize; 15 14 use std::collections::{BTreeMap, HashMap}; 16 15 use std::io::Cursor; 17 16 use std::str::FromStr; 18 17 use tokio::io::AsyncWriteExt; 19 - use tranquil_db_traits::RepoRepository; 18 + use tranquil_db_traits::{AccountStatus, RepoEventType, RepoRepository}; 20 19 use tranquil_types::Did; 21 20 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 21 pub struct RepoAccount { 108 22 pub did: String, 109 23 pub user_id: uuid::Uuid, ··· 233 147 let frame = IdentityFrame { 234 148 did: event.did.to_string(), 235 149 handle: event.handle.as_ref().map(|h| h.to_string()), 236 - seq: event.seq, 150 + seq: event.seq.as_i64(), 237 151 time: format_atproto_time(event.created_at), 238 152 }; 239 153 let header = FrameHeader { ··· 250 164 let frame = AccountFrame { 251 165 did: event.did.to_string(), 252 166 active: event.active.unwrap_or(true), 253 - status: event.status.clone(), 254 - seq: event.seq, 167 + status: event 168 + .status 169 + .and_then(|s| s.for_firehose().map(String::from)), 170 + seq: event.seq.as_i64(), 255 171 time: format_atproto_time(event.created_at), 256 172 }; 257 173 let header = FrameHeader { ··· 298 214 did: event.did.to_string(), 299 215 rev, 300 216 blocks: car_bytes, 301 - seq: event.seq, 217 + seq: event.seq.as_i64(), 302 218 time: format_atproto_time(event.created_at), 303 219 }; 304 220 let header = FrameHeader { ··· 315 231 state: &AppState, 316 232 event: SequencedEvent, 317 233 ) -> 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 - _ => {} 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 => {} 323 239 } 324 240 let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); 325 241 let prev_cid_link = event.prev_cid.clone(); ··· 440 356 did: event.did.to_string(), 441 357 rev, 442 358 blocks: car_bytes, 443 - seq: event.seq, 359 + seq: event.seq.as_i64(), 444 360 time: format_atproto_time(event.created_at), 445 361 }; 446 362 let header = FrameHeader { ··· 457 373 event: SequencedEvent, 458 374 prefetched: &HashMap<Cid, Bytes>, 459 375 ) -> 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 - _ => {} 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 => {} 465 381 } 466 382 let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); 467 383 let prev_cid_link = event.prev_cid.clone();
+6 -10
crates/tranquil-pds/tests/firehose_validation.rs
··· 9 9 use serde_json::{Value, json}; 10 10 use std::io::Cursor; 11 11 use tokio_tungstenite::{connect_async, tungstenite}; 12 + use tranquil_scopes::RepoAction; 12 13 13 14 #[derive(Debug, Deserialize, Serialize)] 14 15 struct FrameHeader { ··· 39 40 40 41 #[derive(Debug, Deserialize)] 41 42 struct RepoOp { 42 - action: String, 43 + action: RepoAction, 43 44 path: String, 44 45 cid: Option<Cid>, 45 46 prev: Option<Cid>, ··· 292 293 println!("\nOps validation:"); 293 294 for (i, op) in frame.ops.iter().enumerate() { 294 295 println!(" Op {}:", i); 295 - println!(" action: {}", op.action); 296 + println!(" action: {:?}", op.action); 296 297 println!(" path: {}", op.path); 297 298 println!(" cid: {:?}", op.cid); 298 299 println!( ··· 300 301 op.prev 301 302 ); 302 303 303 - assert!( 304 - ["create", "update", "delete"].contains(&op.action.as_str()), 305 - "Invalid action: {}", 306 - op.action 307 - ); 308 304 assert!( 309 305 op.path.contains('/'), 310 306 "Path should contain collection/rkey: {}", 311 307 op.path 312 308 ); 313 309 314 - if op.action == "create" { 310 + if op.action == RepoAction::Create { 315 311 assert!(op.cid.is_some(), "Create op should have cid"); 316 312 } 317 313 } ··· 445 441 446 442 for op in &frame.ops { 447 443 println!( 448 - "Op: action={}, path={}, cid={:?}, prev={:?}", 444 + "Op: action={:?}, path={}, cid={:?}, prev={:?}", 449 445 op.action, op.path, op.cid, op.prev 450 446 ); 451 447 452 - if op.action == "update" && op.path.contains("app.bsky.actor.profile") { 448 + if op.action == RepoAction::Update && op.path.contains("app.bsky.actor.profile") { 453 449 assert!( 454 450 op.prev.is_some(), 455 451 "Update operation should have 'prev' field with old CID! Got: {:?}",
+3 -1
crates/tranquil-scopes/src/parser.rs
··· 1 + use serde::{Deserialize, Serialize}; 1 2 use std::collections::{HashMap, HashSet}; 2 3 3 4 #[derive(Debug, Clone, PartialEq, Eq)] ··· 27 28 pub actions: HashSet<RepoAction>, 28 29 } 29 30 30 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 31 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 32 + #[serde(rename_all = "lowercase")] 31 33 pub enum RepoAction { 32 34 Create, 33 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