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

refactor(api): simplify passkey account creation and auth-adjacent server endpoints #84

merged opened by oyster.cafe targeting main from refactor/api
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhi3qdcw4g22
+200 -392
Diff #0
+7 -12
crates/tranquil-api/src/server/app_password.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - response::{IntoResponse, Response}, 5 - }; 1 + use axum::{Json, extract::State}; 6 2 use serde::{Deserialize, Serialize}; 7 3 use serde_json::json; 8 4 use tracing::error; ··· 34 30 pub async fn list_app_passwords( 35 31 State(state): State<AppState>, 36 32 auth: Auth<Permissive>, 37 - ) -> Result<Response, ApiError> { 33 + ) -> Result<Json<ListAppPasswordsOutput>, ApiError> { 38 34 let user = state 39 35 .user_repo 40 36 .get_by_did(&auth.did) ··· 60 56 .map(|d| d.to_string()), 61 57 }) 62 58 .collect(); 63 - Ok(Json(ListAppPasswordsOutput { passwords }).into_response()) 59 + Ok(Json(ListAppPasswordsOutput { passwords })) 64 60 } 65 61 66 62 #[derive(Deserialize)] ··· 86 82 _rate_limit: RateLimited<AppPasswordLimit>, 87 83 auth: Auth<NotTakendown>, 88 84 Json(input): Json<CreateAppPasswordInput>, 89 - ) -> Result<Response, ApiError> { 85 + ) -> Result<Json<CreateAppPasswordOutput>, ApiError> { 90 86 let user = state 91 87 .user_repo 92 88 .get_by_did(&auth.did) ··· 194 190 created_at: created_at.to_rfc3339(), 195 191 privileged: privilege.is_privileged(), 196 192 scopes: final_scopes, 197 - }) 198 - .into_response()) 193 + })) 199 194 } 200 195 201 196 #[derive(Deserialize)] ··· 207 202 State(state): State<AppState>, 208 203 auth: Auth<Permissive>, 209 204 Json(input): Json<RevokeAppPasswordInput>, 210 - ) -> Result<Response, ApiError> { 205 + ) -> Result<Json<EmptyResponse>, ApiError> { 211 206 let user = state 212 207 .user_repo 213 208 .get_by_did(&auth.did) ··· 247 242 .await 248 243 .log_db_err("revoking app password")?; 249 244 250 - Ok(EmptyResponse::ok().into_response()) 245 + Ok(Json(EmptyResponse {})) 251 246 }
+7 -12
crates/tranquil-api/src/server/invite.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - response::{IntoResponse, Response}, 5 - }; 1 + use axum::{Json, extract::State}; 6 2 use rand::Rng; 7 3 use serde::{Deserialize, Serialize}; 8 4 use tracing::error; ··· 46 42 State(state): State<AppState>, 47 43 auth: Auth<Admin>, 48 44 Json(input): Json<CreateInviteCodeInput>, 49 - ) -> Result<Response, ApiError> { 45 + ) -> Result<Json<CreateInviteCodeOutput>, ApiError> { 50 46 if input.use_count < 1 { 51 47 return Err(ApiError::InvalidRequest( 52 48 "useCount must be at least 1".into(), ··· 66 62 .create_invite_code(&code, input.use_count, Some(&for_account)) 67 63 .await 68 64 { 69 - Ok(true) => Ok(Json(CreateInviteCodeOutput { code }).into_response()), 65 + Ok(true) => Ok(Json(CreateInviteCodeOutput { code })), 70 66 Ok(false) => { 71 67 error!("No admin user found to create invite code"); 72 68 Err(ApiError::InternalError(None)) ··· 101 97 State(state): State<AppState>, 102 98 auth: Auth<Admin>, 103 99 Json(input): Json<CreateInviteCodesInput>, 104 - ) -> Result<Response, ApiError> { 100 + ) -> Result<Json<CreateInviteCodesOutput>, ApiError> { 105 101 if input.use_count < 1 { 106 102 return Err(ApiError::InvalidRequest( 107 103 "useCount must be at least 1".into(), ··· 147 143 match result { 148 144 Ok(result_codes) => Ok(Json(CreateInviteCodesOutput { 149 145 codes: result_codes, 150 - }) 151 - .into_response()), 146 + })), 152 147 Err(e) => { 153 148 error!("DB error creating invite codes: {:?}", e); 154 149 Err(ApiError::InternalError(None)) ··· 193 188 State(state): State<AppState>, 194 189 auth: Auth<NotTakendown>, 195 190 axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>, 196 - ) -> Result<Response, ApiError> { 191 + ) -> Result<Json<GetAccountInviteCodesOutput>, ApiError> { 197 192 let include_used = params.include_used.unwrap_or(true); 198 193 199 194 let codes_info = state ··· 247 242 .await; 248 243 249 244 let codes: Vec<InviteCode> = codes.into_iter().flatten().collect(); 250 - Ok(Json(GetAccountInviteCodesOutput { codes }).into_response()) 245 + Ok(Json(GetAccountInviteCodesOutput { codes })) 251 246 }
+8 -17
crates/tranquil-api/src/server/migration.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - http::StatusCode, 5 - response::{IntoResponse, Response}, 6 - }; 1 + use axum::{Json, extract::State}; 7 2 use serde::{Deserialize, Serialize}; 8 3 use serde_json::json; 9 4 use tranquil_pds::api::ApiError; ··· 39 34 State(state): State<AppState>, 40 35 auth: Auth<Active>, 41 36 Json(input): Json<UpdateDidDocumentInput>, 42 - ) -> Result<Response, ApiError> { 37 + ) -> Result<Json<UpdateDidDocumentOutput>, ApiError> { 43 38 if !auth.did.starts_with("did:web:") { 44 39 return Err(ApiError::InvalidRequest( 45 40 "DID document updates are only available for did:web accounts".into(), ··· 120 115 121 116 tracing::info!("Updated DID document for {}", &auth.did); 122 117 123 - Ok(( 124 - StatusCode::OK, 125 - Json(UpdateDidDocumentOutput { 126 - success: true, 127 - did_document: did_doc, 128 - }), 129 - ) 130 - .into_response()) 118 + Ok(Json(UpdateDidDocumentOutput { 119 + success: true, 120 + did_document: did_doc, 121 + })) 131 122 } 132 123 133 124 pub async fn get_did_document( 134 125 State(state): State<AppState>, 135 126 auth: Auth<Active>, 136 - ) -> Result<Response, ApiError> { 127 + ) -> Result<Json<serde_json::Value>, ApiError> { 137 128 if !auth.did.starts_with("did:web:") { 138 129 return Err(ApiError::InvalidRequest( 139 130 "This endpoint is only available for did:web accounts".into(), ··· 142 133 143 134 let did_doc = build_did_document(&state, &auth.did).await; 144 135 145 - Ok((StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()) 136 + Ok(Json(serde_json::json!({ "didDocument": did_doc }))) 146 137 } 147 138 148 139 async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) -> serde_json::Value {
+152 -309
crates/tranquil-api/src/server/passkey_account.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - http::HeaderMap, 5 - response::{IntoResponse, Response}, 6 - }; 7 - use bcrypt::{DEFAULT_COST, hash}; 1 + use crate::common; 2 + use axum::{Json, extract::State, http::HeaderMap}; 8 3 use chrono::{Duration, Utc}; 9 - use jacquard_common::types::{integer::LimitedU32, string::Tid}; 10 - use jacquard_repo::{mst::Mst, storage::BlockStore}; 11 4 use rand::Rng; 12 5 use serde::{Deserialize, Serialize}; 13 6 use serde_json::json; 14 - use std::sync::Arc; 15 7 use tracing::{debug, error, info, warn}; 16 8 use tranquil_db_traits::WebauthnChallengeType; 17 - use tranquil_pds::api::SuccessResponse; 18 9 use tranquil_pds::api::error::ApiError; 10 + use tranquil_pds::api::{OptionsResponse, SuccessResponse}; 19 11 use tranquil_pds::auth::NormalizedLoginIdentifier; 20 - use uuid::Uuid; 21 12 22 13 use tranquil_pds::auth::{ServiceTokenVerifier, generate_app_password, is_service_token}; 23 14 use tranquil_pds::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited}; 24 - use tranquil_pds::repo_ops::create_signed_commit; 25 15 use tranquil_pds::state::AppState; 26 16 use tranquil_pds::types::{Did, Handle, PlainPassword}; 27 17 use tranquil_pds::validation::validate_password; ··· 57 47 58 48 #[derive(Serialize)] 59 49 #[serde(rename_all = "camelCase")] 60 - pub struct CreatePasskeyAccountResponse { 50 + pub struct CreatePasskeyAccountOutput { 61 51 pub did: Did, 62 52 pub handle: Handle, 63 53 pub setup_token: String, ··· 71 61 _rate_limit: RateLimited<AccountCreationLimit>, 72 62 headers: HeaderMap, 73 63 Json(input): Json<CreatePasskeyAccountInput>, 74 - ) -> Response { 64 + ) -> Result<Json<CreatePasskeyAccountOutput>, ApiError> { 75 65 let byod_auth = if let Some(extracted) = tranquil_pds::auth::extract_auth_token_from_header( 76 66 tranquil_pds::util::get_header_str(&headers, http::header::AUTHORIZATION), 77 67 ) { ··· 91 81 } 92 82 Err(e) => { 93 83 error!("Service token verification failed: {:?}", e); 94 - return ApiError::AuthenticationFailed(Some(format!( 84 + return Err(ApiError::AuthenticationFailed(Some(format!( 95 85 "Service token verification failed: {}", 96 86 e 97 - ))) 98 - .into_response(); 87 + )))); 99 88 } 100 89 } 101 90 } else { ··· 116 105 let hostname = &cfg.server.hostname; 117 106 let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) { 118 107 Ok(h) => h, 119 - Err(_) => return ApiError::InvalidHandle(None).into_response(), 108 + Err(_) => return Err(ApiError::InvalidHandle(None)), 120 109 }; 121 110 122 111 let email = input ··· 127 116 if let Some(ref email) = email 128 117 && !tranquil_pds::api::validation::is_valid_email(email) 129 118 { 130 - return ApiError::InvalidEmail.into_response(); 119 + return Err(ApiError::InvalidEmail); 131 120 } 132 121 133 122 let is_bootstrap = state.bootstrap_invite_code.is_some() ··· 136 125 let _validated_invite_code = if is_bootstrap { 137 126 match input.invite_code.as_deref() { 138 127 Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => None, 139 - _ => return ApiError::InvalidInviteCode.into_response(), 128 + _ => return Err(ApiError::InvalidInviteCode), 140 129 } 141 130 } else if let Some(ref code) = input.invite_code { 142 131 match state.infra_repo.validate_invite_code(code).await { 143 132 Ok(validated) => Some(validated), 144 - Err(_) => return ApiError::InvalidInviteCode.into_response(), 133 + Err(_) => return Err(ApiError::InvalidInviteCode), 145 134 } 146 135 } else { 147 136 let invite_required = tranquil_config::get().server.invite_code_required; 148 137 if invite_required { 149 - return ApiError::InviteCodeRequired.into_response(); 138 + return Err(ApiError::InviteCodeRequired); 150 139 } 151 140 None 152 141 }; ··· 154 143 let verification_channel = input 155 144 .verification_channel 156 145 .unwrap_or(tranquil_db_traits::CommsChannel::Email); 157 - let verification_recipient = match verification_channel { 158 - tranquil_db_traits::CommsChannel::Email => match &email { 159 - Some(e) if !e.is_empty() => e.clone(), 160 - _ => return ApiError::MissingEmail.into_response(), 161 - }, 162 - tranquil_db_traits::CommsChannel::Discord => match &input.discord_username { 163 - Some(username) if !username.trim().is_empty() => { 164 - let clean = username.trim().to_lowercase(); 165 - if !tranquil_pds::api::validation::is_valid_discord_username(&clean) { 166 - return ApiError::InvalidRequest( 167 - "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), 168 - ).into_response(); 169 - } 170 - clean 171 - } 172 - _ => return ApiError::MissingDiscordId.into_response(), 173 - }, 174 - tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username { 175 - Some(username) if !username.trim().is_empty() => { 176 - let clean = username.trim().trim_start_matches('@'); 177 - if !tranquil_pds::api::validation::is_valid_telegram_username(clean) { 178 - return ApiError::InvalidRequest( 179 - "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), 180 - ).into_response(); 181 - } 182 - clean.to_string() 183 - } 184 - _ => return ApiError::MissingTelegramUsername.into_response(), 185 - }, 186 - tranquil_db_traits::CommsChannel::Signal => match &input.signal_username { 187 - Some(username) if !username.trim().is_empty() => { 188 - username.trim().trim_start_matches('@').to_lowercase() 189 - } 190 - _ => return ApiError::MissingSignalNumber.into_response(), 146 + let verification_recipient = match common::extract_verification_recipient( 147 + verification_channel, 148 + &common::ChannelInput { 149 + email: email.as_deref(), 150 + discord_username: input.discord_username.as_deref(), 151 + telegram_username: input.telegram_username.as_deref(), 152 + signal_username: input.signal_username.as_deref(), 191 153 }, 154 + ) { 155 + Ok(r) => r, 156 + Err(e) => return Err(e), 192 157 }; 193 158 194 - use k256::ecdsa::SigningKey; 195 - use rand::rngs::OsRng; 196 - 197 159 let pds_endpoint = format!("https://{}", hostname); 198 160 let did_type = input.did_type.as_deref().unwrap_or("plc"); 199 161 200 - let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<Uuid>) = 201 - if let Some(signing_key_did) = &input.signing_key { 202 - match state 203 - .infra_repo 204 - .get_reserved_signing_key(signing_key_did) 205 - .await 206 - { 207 - Ok(Some(reserved)) => (reserved.private_key_bytes, Some(reserved.id)), 208 - Ok(None) => { 209 - return ApiError::InvalidSigningKey.into_response(); 210 - } 211 - Err(e) => { 212 - error!("Error looking up reserved signing key: {:?}", e); 213 - return ApiError::InternalError(None).into_response(); 214 - } 215 - } 216 - } else { 217 - let secret_key = k256::SecretKey::random(&mut OsRng); 218 - (secret_key.to_bytes().to_vec(), None) 162 + let key_result = 163 + match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_deref()) 164 + .await 165 + { 166 + Ok(k) => k, 167 + Err(e) => return Err(e), 219 168 }; 220 - 221 - let secret_key = match SigningKey::from_slice(&secret_key_bytes) { 222 - Ok(k) => k, 223 - Err(e) => { 224 - error!("Error creating signing key: {:?}", e); 225 - return ApiError::InternalError(None).into_response(); 226 - } 227 - }; 169 + let secret_key_bytes = key_result.secret_key_bytes; 170 + let secret_key = key_result.signing_key; 171 + let reserved_key_id = key_result.reserved_key_id; 228 172 229 173 let did = match did_type { 230 174 "web" => { 231 - if !tranquil_pds::util::is_self_hosted_did_web_enabled() { 232 - return ApiError::SelfHostedDidWebDisabled.into_response(); 233 - } 234 - let encoded_handle = handle.replace(':', "%3A"); 235 - let self_hosted_did = format!("did:web:{}", encoded_handle); 175 + let self_hosted_did = match common::create_self_hosted_did_web(&handle) { 176 + Ok(d) => d, 177 + Err(e) => return Err(e), 178 + }; 236 179 info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account"); 237 180 self_hosted_did 238 181 } ··· 240 183 let d = match &input.did { 241 184 Some(d) if !d.trim().is_empty() => d.trim(), 242 185 _ => { 243 - return ApiError::InvalidRequest( 186 + return Err(ApiError::InvalidRequest( 244 187 "External did:web requires the 'did' field to be provided".into(), 245 - ) 246 - .into_response(); 188 + )); 247 189 } 248 190 }; 249 191 if !d.starts_with("did:web:") { 250 - return ApiError::InvalidDid("External DID must be a did:web".into()) 251 - .into_response(); 192 + return Err(ApiError::InvalidDid( 193 + "External DID must be a did:web".into(), 194 + )); 252 195 } 253 196 if is_byod_did_web { 254 197 if let Some(ref auth_did) = byod_auth 255 198 && d != auth_did.as_str() 256 199 { 257 - return ApiError::AuthorizationError(format!( 200 + return Err(ApiError::AuthorizationError(format!( 258 201 "Service token issuer {} does not match DID {}", 259 202 auth_did, d 260 - )) 261 - .into_response(); 203 + ))); 262 204 } 263 205 info!(did = %d, "Creating external did:web passkey account (BYOD key)"); 264 206 } else { ··· 270 212 ) 271 213 .await 272 214 { 273 - return ApiError::InvalidDid(e.to_string()).into_response(); 215 + return Err(ApiError::InvalidDid(e.to_string())); 274 216 } 275 217 info!(did = %d, "Creating external did:web passkey account (reserved key)"); 276 218 } ··· 281 223 if let Some(ref provided_did) = input.did { 282 224 if provided_did.starts_with("did:plc:") { 283 225 if provided_did != auth_did.as_str() { 284 - return ApiError::AuthorizationError(format!( 226 + return Err(ApiError::AuthorizationError(format!( 285 227 "Service token issuer {} does not match DID {}", 286 228 auth_did, provided_did 287 - )) 288 - .into_response(); 229 + ))); 289 230 } 290 231 info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)"); 291 232 provided_did.clone() 292 233 } else { 293 - return ApiError::InvalidRequest( 234 + return Err(ApiError::InvalidRequest( 294 235 "BYOD migration requires a did:plc or did:web DID".into(), 295 - ) 296 - .into_response(); 236 + )); 297 237 } 298 238 } else { 299 - return ApiError::InvalidRequest( 239 + return Err(ApiError::InvalidRequest( 300 240 "BYOD migration requires the 'did' field".into(), 301 - ) 302 - .into_response(); 241 + )); 303 242 } 304 243 } else { 305 244 let rotation_key = tranquil_config::get() ··· 317 256 Ok(r) => r, 318 257 Err(e) => { 319 258 error!("Error creating PLC genesis operation: {:?}", e); 320 - return ApiError::InternalError(Some( 259 + return Err(ApiError::InternalError(Some( 321 260 "Failed to create PLC operation".into(), 322 - )) 323 - .into_response(); 261 + ))); 324 262 } 325 263 }; 326 264 ··· 331 269 .await 332 270 { 333 271 error!("Failed to submit PLC genesis operation: {:?}", e); 334 - return ApiError::UpstreamErrorMsg(format!( 272 + return Err(ApiError::UpstreamErrorMsg(format!( 335 273 "Failed to register DID with PLC directory: {}", 336 274 e 337 - )) 338 - .into_response(); 275 + ))); 339 276 } 340 277 genesis_result.did 341 278 } ··· 345 282 info!(did = %did, handle = %handle, "Created DID for passkey-only account"); 346 283 347 284 let setup_token = generate_setup_token(); 348 - let setup_token_hash = match hash(&setup_token, DEFAULT_COST) { 349 - Ok(h) => h, 350 - Err(e) => { 351 - error!("Error hashing setup token: {:?}", e); 352 - return ApiError::InternalError(None).into_response(); 353 - } 354 - }; 285 + let setup_token_hash = common::hash_or_internal_error(&setup_token)?; 355 286 let setup_expires_at = Utc::now() + Duration::hours(1); 356 287 357 288 let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web { ··· 360 291 None 361 292 }; 362 293 363 - let encrypted_key_bytes = match tranquil_pds::config::encrypt_key(&secret_key_bytes) { 364 - Ok(bytes) => bytes, 365 - Err(e) => { 366 - error!("Error encrypting signing key: {:?}", e); 367 - return ApiError::InternalError(None).into_response(); 368 - } 369 - }; 370 - 371 - let mst = Mst::new(Arc::new(state.block_store.clone())); 372 - let mst_root = match mst.persist().await { 373 - Ok(c) => c, 374 - Err(e) => { 375 - error!("Error persisting MST: {:?}", e); 376 - return ApiError::InternalError(None).into_response(); 377 - } 378 - }; 379 - let rev = Tid::now(LimitedU32::MIN); 380 294 let did_typed: Did = match did.parse() { 381 295 Ok(d) => d, 382 - Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(), 296 + Err(_) => return Err(ApiError::InternalError(Some("Invalid DID".into()))), 383 297 }; 384 - let (commit_bytes, _sig) = 385 - match create_signed_commit(&did_typed, mst_root, rev.as_ref(), None, &secret_key) { 386 - Ok(result) => result, 387 - Err(e) => { 388 - error!("Error creating genesis commit: {:?}", e); 389 - return ApiError::InternalError(None).into_response(); 390 - } 391 - }; 392 - let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { 393 - Ok(c) => c, 394 - Err(e) => { 395 - error!("Error saving genesis commit: {:?}", e); 396 - return ApiError::InternalError(None).into_response(); 397 - } 298 + let repo = match crate::identity::provision::init_genesis_repo( 299 + &state, 300 + &did_typed, 301 + &secret_key, 302 + &secret_key_bytes, 303 + ) 304 + .await 305 + { 306 + Ok(r) => r, 307 + Err(e) => return Err(e), 398 308 }; 399 - let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 400 309 401 310 let birthdate_pref = if tranquil_config::get().server.age_assurance_override { 402 311 Some(json!({ ··· 409 318 410 319 let handle_typed: Handle = match handle.parse() { 411 320 Ok(h) => h, 412 - Err(_) => return ApiError::InvalidHandle(None).into_response(), 321 + Err(_) => return Err(ApiError::InvalidHandle(None)), 413 322 }; 323 + let repo_for_seq = repo.clone(); 324 + let comms = crate::identity::provision::normalize_comms_usernames( 325 + input.discord_username.as_deref(), 326 + input.telegram_username.as_deref(), 327 + input.signal_username.as_deref(), 328 + ); 414 329 let create_input = tranquil_db_traits::CreatePasskeyAccountInput { 415 330 handle: handle_typed.clone(), 416 331 email: email.clone().unwrap_or_default(), 417 332 did: did_typed.clone(), 418 333 preferred_comms_channel: verification_channel, 419 - discord_username: input 420 - .discord_username 421 - .as_deref() 422 - .map(|s| s.trim().to_lowercase()) 423 - .filter(|s| !s.is_empty()), 424 - telegram_username: input 425 - .telegram_username 426 - .as_deref() 427 - .map(|s| s.trim().trim_start_matches('@')) 428 - .filter(|s| !s.is_empty()) 429 - .map(String::from), 430 - signal_username: input 431 - .signal_username 432 - .as_deref() 433 - .map(|s| s.trim().trim_start_matches('@')) 434 - .filter(|s| !s.is_empty()) 435 - .map(|s| s.to_lowercase()), 334 + discord_username: comms.discord, 335 + telegram_username: comms.telegram, 336 + signal_username: comms.signal, 436 337 setup_token_hash, 437 338 setup_expires_at, 438 339 deactivated_at, 439 - encrypted_key_bytes, 340 + encrypted_key_bytes: repo.encrypted_key_bytes, 440 341 encryption_version: tranquil_pds::config::ENCRYPTION_VERSION, 441 342 reserved_key_id, 442 - commit_cid: commit_cid.to_string(), 443 - repo_rev: rev.as_ref().to_string(), 444 - genesis_block_cids, 343 + commit_cid: repo.commit_cid.to_string(), 344 + repo_rev: repo.repo_rev.clone(), 345 + genesis_block_cids: repo.genesis_block_cids, 445 346 invite_code: if is_bootstrap { 446 347 None 447 348 } else { ··· 453 354 let create_result = match state.user_repo.create_passkey_account(&create_input).await { 454 355 Ok(r) => r, 455 356 Err(tranquil_db_traits::CreateAccountError::HandleTaken) => { 456 - return ApiError::HandleNotAvailable(None).into_response(); 357 + return Err(ApiError::HandleNotAvailable(None)); 457 358 } 458 359 Err(tranquil_db_traits::CreateAccountError::EmailTaken) => { 459 - return ApiError::EmailTaken.into_response(); 360 + return Err(ApiError::EmailTaken); 460 361 } 461 362 Err(e) => { 462 363 error!("Error creating passkey account: {:?}", e); 463 - return ApiError::InternalError(None).into_response(); 364 + return Err(ApiError::InternalError(None)); 464 365 } 465 366 }; 466 367 let user_id = create_result.user_id; 467 368 468 369 if !is_byod_did_web { 469 - if let Err(e) = 470 - tranquil_pds::repo_ops::sequence_identity_event(&state, &did_typed, Some(&handle_typed)) 471 - .await 472 - { 473 - warn!("Failed to sequence identity event for {}: {}", did, e); 474 - } 475 - if let Err(e) = tranquil_pds::repo_ops::sequence_account_event( 476 - &state, 477 - &did_typed, 478 - tranquil_db_traits::AccountStatus::Active, 479 - ) 480 - .await 481 - { 482 - warn!("Failed to sequence account event for {}: {}", did, e); 483 - } 484 - let profile_record = serde_json::json!({ 485 - "$type": "app.bsky.actor.profile", 486 - "displayName": handle 487 - }); 488 - if let Err(e) = tranquil_pds::repo_ops::create_record_internal( 370 + crate::identity::provision::sequence_new_account( 489 371 &state, 490 372 &did_typed, 491 - &tranquil_pds::types::PROFILE_COLLECTION, 492 - &tranquil_pds::types::PROFILE_RKEY, 493 - &profile_record, 373 + &handle_typed, 374 + &repo_for_seq, 375 + &handle, 494 376 ) 495 - .await 496 - { 497 - warn!("Failed to create default profile for {}: {}", did, e); 498 - } 377 + .await; 499 378 } 500 379 501 - let verification_token = tranquil_pds::auth::verification_token::generate_signup_token( 502 - &did_typed, 503 - verification_channel, 504 - &verification_recipient, 505 - ); 506 - let formatted_token = 507 - tranquil_pds::auth::verification_token::format_token_for_display(&verification_token); 508 - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification( 509 - state.user_repo.as_ref(), 510 - state.infra_repo.as_ref(), 380 + crate::identity::provision::enqueue_signup_verification( 381 + &state, 511 382 user_id, 383 + &did_typed, 512 384 verification_channel, 513 385 &verification_recipient, 514 - &formatted_token, 515 - hostname, 516 386 ) 517 - .await 518 - { 519 - warn!("Failed to enqueue signup verification: {:?}", e); 520 - } 387 + .await; 521 388 522 389 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); 523 390 ··· 553 420 None 554 421 }; 555 422 556 - Json(CreatePasskeyAccountResponse { 423 + Ok(Json(CreatePasskeyAccountOutput { 557 424 did: did.into(), 558 425 handle: handle.into(), 559 426 setup_token, 560 427 setup_expires_at, 561 428 access_jwt, 562 - }) 563 - .into_response() 429 + })) 564 430 } 565 431 566 432 #[derive(Deserialize)] ··· 574 440 575 441 #[derive(Serialize)] 576 442 #[serde(rename_all = "camelCase")] 577 - pub struct CompletePasskeySetupResponse { 443 + pub struct CompletePasskeySetupOutput { 578 444 pub did: Did, 579 445 pub handle: Handle, 580 446 pub app_password: String, ··· 584 450 pub async fn complete_passkey_setup( 585 451 State(state): State<AppState>, 586 452 Json(input): Json<CompletePasskeySetupInput>, 587 - ) -> Response { 453 + ) -> Result<Json<CompletePasskeySetupOutput>, ApiError> { 588 454 let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await { 589 455 Ok(Some(u)) => u, 590 456 Ok(None) => { 591 - return ApiError::AccountNotFound.into_response(); 457 + return Err(ApiError::AccountNotFound); 592 458 } 593 459 Err(e) => { 594 460 error!("DB error: {:?}", e); 595 - return ApiError::InternalError(None).into_response(); 461 + return Err(ApiError::InternalError(None)); 596 462 } 597 463 }; 598 464 599 465 if user.password_required { 600 - return ApiError::InvalidAccount.into_response(); 466 + return Err(ApiError::InvalidAccount); 601 467 } 602 468 603 469 let token_hash = match &user.recovery_token { 604 470 Some(h) => h, 605 471 None => { 606 - return ApiError::SetupExpired.into_response(); 472 + return Err(ApiError::SetupExpired); 607 473 } 608 474 }; 609 475 610 - if let Some(expires_at) = user.recovery_token_expires_at 611 - && expires_at < Utc::now() 612 - { 613 - return ApiError::SetupExpired.into_response(); 614 - } 615 - 616 - if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { 617 - return ApiError::InvalidToken(None).into_response(); 618 - } 476 + common::validate_token_hash( 477 + user.recovery_token_expires_at, 478 + token_hash, 479 + &input.setup_token, 480 + ApiError::SetupExpired, 481 + ApiError::InvalidToken(None), 482 + )?; 619 483 620 484 let webauthn = &state.webauthn_config; 621 485 ··· 628 492 Ok(s) => s, 629 493 Err(e) => { 630 494 error!("Error deserializing registration state: {:?}", e); 631 - return ApiError::InternalError(None).into_response(); 495 + return Err(ApiError::InternalError(None)); 632 496 } 633 497 }, 634 498 Ok(None) => { 635 - return ApiError::NoChallengeInProgress.into_response(); 499 + return Err(ApiError::NoChallengeInProgress); 636 500 } 637 501 Err(e) => { 638 502 error!("Error loading registration state: {:?}", e); 639 - return ApiError::InternalError(None).into_response(); 503 + return Err(ApiError::InternalError(None)); 640 504 } 641 505 }; 642 506 ··· 645 509 Ok(c) => c, 646 510 Err(e) => { 647 511 warn!("Failed to parse credential: {:?}", e); 648 - return ApiError::InvalidCredential.into_response(); 512 + return Err(ApiError::InvalidCredential); 649 513 } 650 514 }; 651 515 ··· 653 517 Ok(sk) => sk, 654 518 Err(e) => { 655 519 warn!("Passkey registration failed: {:?}", e); 656 - return ApiError::RegistrationFailed.into_response(); 520 + return Err(ApiError::RegistrationFailed); 657 521 } 658 522 }; 659 523 ··· 662 526 Ok(pk) => pk, 663 527 Err(e) => { 664 528 error!("Error serializing security key: {:?}", e); 665 - return ApiError::InternalError(None).into_response(); 529 + return Err(ApiError::InternalError(None)); 666 530 } 667 531 }; 668 532 if let Err(e) = state ··· 676 540 .await 677 541 { 678 542 error!("Error saving passkey: {:?}", e); 679 - return ApiError::InternalError(None).into_response(); 543 + return Err(ApiError::InternalError(None)); 680 544 } 681 545 682 546 let app_password = generate_app_password(); 683 547 let app_password_name = "bsky.app".to_string(); 684 - let password_hash = match hash(&app_password, DEFAULT_COST) { 685 - Ok(h) => h, 686 - Err(e) => { 687 - error!("Error hashing app password: {:?}", e); 688 - return ApiError::InternalError(None).into_response(); 689 - } 690 - }; 548 + let password_hash = common::hash_or_internal_error(&app_password)?; 691 549 692 550 let setup_input = tranquil_db_traits::CompletePasskeySetupInput { 693 551 user_id: user.id, ··· 697 555 }; 698 556 if let Err(e) = state.user_repo.complete_passkey_setup(&setup_input).await { 699 557 error!("Error completing passkey setup: {:?}", e); 700 - return ApiError::InternalError(None).into_response(); 558 + return Err(ApiError::InternalError(None)); 701 559 } 702 560 703 561 let _ = state ··· 707 565 708 566 info!(did = %input.did, "Passkey-only account setup completed"); 709 567 710 - Json(CompletePasskeySetupResponse { 568 + Ok(Json(CompletePasskeySetupOutput { 711 569 did: input.did.clone(), 712 570 handle: user.handle, 713 571 app_password, 714 572 app_password_name, 715 - }) 716 - .into_response() 573 + })) 717 574 } 718 575 719 576 pub async fn start_passkey_registration_for_setup( 720 577 State(state): State<AppState>, 721 578 Json(input): Json<StartPasskeyRegistrationInput>, 722 - ) -> Response { 579 + ) -> Result<Json<OptionsResponse<serde_json::Value>>, ApiError> { 723 580 let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await { 724 581 Ok(Some(u)) => u, 725 582 Ok(None) => { 726 - return ApiError::AccountNotFound.into_response(); 583 + return Err(ApiError::AccountNotFound); 727 584 } 728 585 Err(e) => { 729 586 error!("DB error: {:?}", e); 730 - return ApiError::InternalError(None).into_response(); 587 + return Err(ApiError::InternalError(None)); 731 588 } 732 589 }; 733 590 734 591 if user.password_required { 735 - return ApiError::InvalidAccount.into_response(); 592 + return Err(ApiError::InvalidAccount); 736 593 } 737 594 738 595 let token_hash = match &user.recovery_token { 739 596 Some(h) => h, 740 597 None => { 741 - return ApiError::SetupExpired.into_response(); 598 + return Err(ApiError::SetupExpired); 742 599 } 743 600 }; 744 601 745 - if let Some(expires_at) = user.recovery_token_expires_at 746 - && expires_at < Utc::now() 747 - { 748 - return ApiError::SetupExpired.into_response(); 749 - } 750 - 751 - if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { 752 - return ApiError::InvalidToken(None).into_response(); 753 - } 602 + common::validate_token_hash( 603 + user.recovery_token_expires_at, 604 + token_hash, 605 + &input.setup_token, 606 + ApiError::SetupExpired, 607 + ApiError::InvalidToken(None), 608 + )?; 754 609 755 610 let webauthn = &state.webauthn_config; 756 611 ··· 776 631 Ok(result) => result, 777 632 Err(e) => { 778 633 error!("Failed to start passkey registration: {:?}", e); 779 - return ApiError::InternalError(None).into_response(); 634 + return Err(ApiError::InternalError(None)); 780 635 } 781 636 }; 782 637 ··· 784 639 Ok(json) => json, 785 640 Err(e) => { 786 641 error!("Failed to serialize registration state: {:?}", e); 787 - return ApiError::InternalError(None).into_response(); 642 + return Err(ApiError::InternalError(None)); 788 643 } 789 644 }; 790 645 if let Err(e) = state ··· 793 648 .await 794 649 { 795 650 error!("Failed to save registration state: {:?}", e); 796 - return ApiError::InternalError(None).into_response(); 651 + return Err(ApiError::InternalError(None)); 797 652 } 798 653 799 654 let options = serde_json::to_value(&ccr).unwrap_or(json!({})); 800 - Json(json!({"options": options})).into_response() 655 + Ok(OptionsResponse::new(options)) 801 656 } 802 657 803 658 #[derive(Deserialize)] ··· 819 674 State(state): State<AppState>, 820 675 _rate_limit: RateLimited<PasswordResetLimit>, 821 676 Json(input): Json<RequestPasskeyRecoveryInput>, 822 - ) -> Response { 677 + ) -> Result<Json<SuccessResponse>, ApiError> { 823 678 let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 824 679 let identifier = input.email.trim().to_lowercase(); 825 680 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); ··· 833 688 { 834 689 Ok(Some(u)) if !u.password_required => u, 835 690 _ => { 836 - return SuccessResponse::ok().into_response(); 691 + return Ok(Json(SuccessResponse { success: true })); 837 692 } 838 693 }; 839 694 840 695 let recovery_token = generate_setup_token(); 841 - let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) { 842 - Ok(h) => h, 843 - Err(_) => { 844 - return ApiError::InternalError(None).into_response(); 845 - } 846 - }; 696 + let recovery_token_hash = common::hash_or_internal_error(&recovery_token)?; 847 697 let expires_at = Utc::now() + Duration::hours(1); 848 698 849 699 if let Err(e) = state ··· 852 702 .await 853 703 { 854 704 error!("Error updating recovery token: {:?}", e); 855 - return ApiError::InternalError(None).into_response(); 705 + return Err(ApiError::InternalError(None)); 856 706 } 857 707 858 708 let hostname = &tranquil_config::get().server.hostname; ··· 873 723 .await; 874 724 875 725 info!(did = %user.did, "Passkey recovery requested"); 876 - SuccessResponse::ok().into_response() 726 + Ok(Json(SuccessResponse { success: true })) 877 727 } 878 728 879 729 #[derive(Deserialize)] ··· 887 737 pub async fn recover_passkey_account( 888 738 State(state): State<AppState>, 889 739 Json(input): Json<RecoverPasskeyAccountInput>, 890 - ) -> Response { 740 + ) -> Result<Json<SuccessResponse>, ApiError> { 891 741 if let Err(e) = validate_password(&input.new_password) { 892 - return ApiError::InvalidRequest(e.to_string()).into_response(); 742 + return Err(ApiError::InvalidRequest(e.to_string())); 893 743 } 894 744 895 745 let user = match state.user_repo.get_user_for_recovery(&input.did).await { 896 746 Ok(Some(u)) => u, 897 747 _ => { 898 - return ApiError::InvalidRecoveryLink.into_response(); 748 + return Err(ApiError::InvalidRecoveryLink); 899 749 } 900 750 }; 901 751 902 752 let token_hash = match &user.recovery_token { 903 753 Some(h) => h, 904 754 None => { 905 - return ApiError::InvalidRecoveryLink.into_response(); 755 + return Err(ApiError::InvalidRecoveryLink); 906 756 } 907 757 }; 908 758 909 - if let Some(expires_at) = user.recovery_token_expires_at 910 - && expires_at < Utc::now() 911 - { 912 - return ApiError::RecoveryLinkExpired.into_response(); 913 - } 914 - 915 - if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) { 916 - return ApiError::InvalidRecoveryLink.into_response(); 917 - } 759 + common::validate_token_hash( 760 + user.recovery_token_expires_at, 761 + token_hash, 762 + &input.recovery_token, 763 + ApiError::RecoveryLinkExpired, 764 + ApiError::InvalidRecoveryLink, 765 + )?; 918 766 919 - let password_hash = match hash(&input.new_password, DEFAULT_COST) { 920 - Ok(h) => h, 921 - Err(_) => { 922 - return ApiError::InternalError(None).into_response(); 923 - } 924 - }; 767 + let password_hash = common::hash_or_internal_error(&input.new_password)?; 925 768 926 769 let recover_input = tranquil_db_traits::RecoverPasskeyAccountInput { 927 770 did: input.did.clone(), ··· 935 778 Ok(r) => r, 936 779 Err(e) => { 937 780 error!("Error recovering passkey account: {:?}", e); 938 - return ApiError::InternalError(None).into_response(); 781 + return Err(ApiError::InternalError(None)); 939 782 } 940 783 }; 941 784 ··· 957 800 } 958 801 } 959 802 info!(did = %input.did, "Passkey-only account recovered with temporary password"); 960 - SuccessResponse::ok().into_response() 803 + Ok(Json(SuccessResponse { success: true })) 961 804 }
+18 -30
crates/tranquil-api/src/server/passkeys.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - response::{IntoResponse, Response}, 5 - }; 1 + use axum::{Json, extract::State}; 6 2 use serde::{Deserialize, Serialize}; 7 3 use tracing::{error, info, warn}; 8 4 use tranquil_db_traits::WebauthnChallengeType; ··· 20 16 21 17 #[derive(Serialize)] 22 18 #[serde(rename_all = "camelCase")] 23 - pub struct StartRegistrationResponse { 19 + pub struct StartRegistrationOutput { 24 20 pub options: serde_json::Value, 25 21 } 26 22 ··· 28 24 State(state): State<AppState>, 29 25 auth: Auth<Active>, 30 26 Json(input): Json<StartRegistrationInput>, 31 - ) -> Result<Response, ApiError> { 27 + ) -> Result<Json<StartRegistrationOutput>, ApiError> { 32 28 let webauthn = &state.webauthn_config; 33 29 34 30 let handle = state ··· 73 69 74 70 info!(did = %auth.did, "Passkey registration started"); 75 71 76 - Ok(Json(StartRegistrationResponse { options }).into_response()) 72 + Ok(Json(StartRegistrationOutput { options })) 77 73 } 78 74 79 75 #[derive(Deserialize)] ··· 85 81 86 82 #[derive(Serialize)] 87 83 #[serde(rename_all = "camelCase")] 88 - pub struct FinishRegistrationResponse { 84 + pub struct FinishRegistrationOutput { 89 85 pub id: String, 90 86 pub credential_id: String, 91 87 } ··· 94 90 State(state): State<AppState>, 95 91 auth: Auth<Active>, 96 92 Json(input): Json<FinishRegistrationInput>, 97 - ) -> Result<Response, ApiError> { 93 + ) -> Result<Json<FinishRegistrationOutput>, ApiError> { 98 94 let webauthn = &state.webauthn_config; 99 95 100 96 let reg_state_json = state ··· 154 150 155 151 info!(did = %auth.did, passkey_id = %passkey_id, "Passkey registered"); 156 152 157 - Ok(Json(FinishRegistrationResponse { 153 + Ok(Json(FinishRegistrationOutput { 158 154 id: passkey_id.to_string(), 159 155 credential_id: credential_id_base64, 160 - }) 161 - .into_response()) 156 + })) 162 157 } 163 158 164 159 #[derive(Serialize)] ··· 173 168 174 169 #[derive(Serialize)] 175 170 #[serde(rename_all = "camelCase")] 176 - pub struct ListPasskeysResponse { 171 + pub struct ListPasskeysOutput { 177 172 pub passkeys: Vec<PasskeyInfo>, 178 173 } 179 174 180 175 pub async fn list_passkeys( 181 176 State(state): State<AppState>, 182 177 auth: Auth<Active>, 183 - ) -> Result<Response, ApiError> { 178 + ) -> Result<Json<ListPasskeysOutput>, ApiError> { 184 179 let passkeys = state 185 180 .user_repo 186 181 .get_passkeys_for_user(&auth.did) ··· 198 193 }) 199 194 .collect(); 200 195 201 - Ok(Json(ListPasskeysResponse { 196 + Ok(Json(ListPasskeysOutput { 202 197 passkeys: passkey_infos, 203 - }) 204 - .into_response()) 198 + })) 205 199 } 206 200 207 201 #[derive(Deserialize)] ··· 214 208 State(state): State<AppState>, 215 209 auth: Auth<Active>, 216 210 Json(input): Json<DeletePasskeyInput>, 217 - ) -> Result<Response, ApiError> { 218 - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { 219 - Ok(proof) => proof, 220 - Err(response) => return Ok(response), 221 - }; 211 + ) -> Result<Json<EmptyResponse>, ApiError> { 212 + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; 222 213 223 - let reauth_mfa = match require_reauth_window(&state, &auth).await { 224 - Ok(proof) => proof, 225 - Err(response) => return Ok(response), 226 - }; 214 + let reauth_mfa = require_reauth_window(&state, &auth).await?; 227 215 228 216 let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?; 229 217 230 218 match state.user_repo.delete_passkey(id, reauth_mfa.did()).await { 231 219 Ok(true) => { 232 220 info!(did = %session_mfa.did(), passkey_id = %id, "Passkey deleted"); 233 - Ok(EmptyResponse::ok().into_response()) 221 + Ok(Json(EmptyResponse {})) 234 222 } 235 223 Ok(false) => Err(ApiError::PasskeyNotFound), 236 224 Err(e) => { ··· 251 239 State(state): State<AppState>, 252 240 auth: Auth<Active>, 253 241 Json(input): Json<UpdatePasskeyInput>, 254 - ) -> Result<Response, ApiError> { 242 + ) -> Result<Json<EmptyResponse>, ApiError> { 255 243 let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?; 256 244 257 245 match state ··· 261 249 { 262 250 Ok(true) => { 263 251 info!(did = %auth.did, passkey_id = %id, "Passkey renamed"); 264 - Ok(EmptyResponse::ok().into_response()) 252 + Ok(Json(EmptyResponse {})) 265 253 } 266 254 Ok(false) => Err(ApiError::PasskeyNotFound), 267 255 Err(e) => {
+8 -12
crates/tranquil-api/src/server/trusted_devices.rs
··· 1 - use axum::{ 2 - Json, 3 - extract::State, 4 - response::{IntoResponse, Response}, 5 - }; 1 + use axum::{Json, extract::State}; 6 2 use chrono::{DateTime, Duration, Utc}; 7 3 use serde::{Deserialize, Serialize}; 8 4 use tracing::{error, info}; ··· 67 63 68 64 #[derive(Serialize)] 69 65 #[serde(rename_all = "camelCase")] 70 - pub struct ListTrustedDevicesResponse { 66 + pub struct ListTrustedDevicesOutput { 71 67 pub devices: Vec<TrustedDevice>, 72 68 } 73 69 74 70 pub async fn list_trusted_devices( 75 71 State(state): State<AppState>, 76 72 auth: Auth<Active>, 77 - ) -> Result<Response, ApiError> { 73 + ) -> Result<Json<ListTrustedDevicesOutput>, ApiError> { 78 74 let rows = state 79 75 .oauth_repo 80 76 .list_trusted_devices(&auth.did) ··· 97 93 }) 98 94 .collect(); 99 95 100 - Ok(Json(ListTrustedDevicesResponse { devices }).into_response()) 96 + Ok(Json(ListTrustedDevicesOutput { devices })) 101 97 } 102 98 103 99 #[derive(Deserialize)] ··· 110 106 State(state): State<AppState>, 111 107 auth: Auth<Active>, 112 108 Json(input): Json<RevokeTrustedDeviceInput>, 113 - ) -> Result<Response, ApiError> { 109 + ) -> Result<Json<SuccessResponse>, ApiError> { 114 110 match state 115 111 .oauth_repo 116 112 .device_belongs_to_user(&input.device_id, &auth.did) ··· 133 129 .log_db_err("revoking device trust")?; 134 130 135 131 info!(did = %&auth.did, device_id = %input.device_id, "Trusted device revoked"); 136 - Ok(SuccessResponse::ok().into_response()) 132 + Ok(Json(SuccessResponse { success: true })) 137 133 } 138 134 139 135 #[derive(Deserialize)] ··· 147 143 State(state): State<AppState>, 148 144 auth: Auth<Active>, 149 145 Json(input): Json<UpdateTrustedDeviceInput>, 150 - ) -> Result<Response, ApiError> { 146 + ) -> Result<Json<SuccessResponse>, ApiError> { 151 147 match state 152 148 .oauth_repo 153 149 .device_belongs_to_user(&input.device_id, &auth.did) ··· 170 166 .log_db_err("updating device friendly name")?; 171 167 172 168 info!(did = %auth.did, device_id = %input.device_id, "Trusted device updated"); 173 - Ok(SuccessResponse::ok().into_response()) 169 + Ok(Json(SuccessResponse { success: true })) 174 170 } 175 171 176 172 pub async fn get_device_trust_state(

History

1 round 0 comments
sign up or login to add to the discussion
oyster.cafe submitted #0
1 commit
expand
refactor(api): simplify passkey account creation and auth-adjacent server endpoints
expand 0 comments
pull request successfully merged