this repo has no description

Outbound migration perfected

lewis 0923909a a4d6d0fd

Changed files
+183 -18
frontend
src
src
api
admin
identity
repo
record
sync
+2
frontend/src/lib/api.ts
··· 48 48 preferredChannel?: string 49 49 preferredChannelVerified?: boolean 50 50 isAdmin?: boolean 51 + active?: boolean 52 + status?: 'active' | 'deactivated' 51 53 accessJwt: string 52 54 refreshJwt: string 53 55 }
+30
frontend/src/routes/Dashboard.svelte
··· 84 84 {/if} 85 85 </div> 86 86 </header> 87 + {#if auth.session.status === 'deactivated' || auth.session.active === false} 88 + <div class="deactivated-banner"> 89 + <strong>Account Deactivated</strong> 90 + <p>Your account is currently deactivated. This typically happens during account migration. Some features may be limited until your account is reactivated.</p> 91 + </div> 92 + {/if} 87 93 <section class="account-overview"> 88 94 <h2>Account Overview</h2> 89 95 <dl> ··· 92 98 @{auth.session.handle} 93 99 {#if auth.session.isAdmin} 94 100 <span class="badge admin">Admin</span> 101 + {/if} 102 + {#if auth.session.status === 'deactivated' || auth.session.active === false} 103 + <span class="badge deactivated">Deactivated</span> 95 104 {/if} 96 105 </dd> 97 106 <dt>DID</dt> ··· 301 310 background: var(--accent); 302 311 color: white; 303 312 } 313 + .badge.deactivated { 314 + background: var(--warning-bg); 315 + color: var(--warning-text); 316 + border: 1px solid #d4a03c; 317 + } 304 318 .nav-grid { 305 319 display: grid; 306 320 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); ··· 340 354 text-align: center; 341 355 padding: 4rem; 342 356 color: var(--text-secondary); 357 + } 358 + .deactivated-banner { 359 + background: var(--warning-bg); 360 + border: 1px solid #d4a03c; 361 + border-radius: 8px; 362 + padding: 1rem 1.5rem; 363 + margin-bottom: 2rem; 364 + } 365 + .deactivated-banner strong { 366 + color: var(--warning-text); 367 + font-size: 1rem; 368 + } 369 + .deactivated-banner p { 370 + margin: 0.5rem 0 0 0; 371 + color: var(--warning-text); 372 + font-size: 0.875rem; 343 373 } 344 374 </style>
+1
src/api/admin/invite.rs
··· 78 78 79 79 #[derive(Serialize)] 80 80 pub struct GetInviteCodesOutput { 81 + #[serde(skip_serializing_if = "Option::is_none")] 81 82 pub cursor: Option<String>, 82 83 pub codes: Vec<InviteCodeInfo>, 83 84 }
+146 -18
src/api/identity/account.rs
··· 364 364 .into_response(); 365 365 } 366 366 }; 367 - let exists_query = sqlx::query!("SELECT 1 as one FROM users WHERE handle = $1", short_handle) 368 - .fetch_optional(&mut *tx) 369 - .await; 370 - match exists_query { 371 - Ok(Some(_)) => { 372 - return ( 373 - StatusCode::BAD_REQUEST, 374 - Json(json!({"error": "HandleTaken", "message": "Handle already taken"})), 367 + if is_migration { 368 + let existing_account: Option<(uuid::Uuid, String, Option<chrono::DateTime<chrono::Utc>>)> = 369 + sqlx::query_as( 370 + "SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE", 375 371 ) 376 - .into_response(); 377 - } 378 - Err(e) => { 379 - error!("Error checking handle: {:?}", e); 380 - return ( 381 - StatusCode::INTERNAL_SERVER_ERROR, 382 - Json(json!({"error": "InternalError"})), 383 - ) 384 - .into_response(); 372 + .bind(&did) 373 + .fetch_optional(&mut *tx) 374 + .await 375 + .unwrap_or(None); 376 + if let Some((account_id, old_handle, deactivated_at)) = existing_account { 377 + if deactivated_at.is_some() { 378 + info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration"); 379 + let update_result: Result<_, sqlx::Error> = sqlx::query( 380 + "UPDATE users SET handle = $1 WHERE id = $2", 381 + ) 382 + .bind(short_handle) 383 + .bind(account_id) 384 + .execute(&mut *tx) 385 + .await; 386 + if let Err(e) = update_result { 387 + if let Some(db_err) = e.as_database_error() { 388 + if db_err.constraint().map(|c| c.contains("handle")).unwrap_or(false) { 389 + return ( 390 + StatusCode::BAD_REQUEST, 391 + Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})), 392 + ) 393 + .into_response(); 394 + } 395 + } 396 + error!("Error reactivating account: {:?}", e); 397 + return ( 398 + StatusCode::INTERNAL_SERVER_ERROR, 399 + Json(json!({"error": "InternalError"})), 400 + ) 401 + .into_response(); 402 + } 403 + if let Err(e) = tx.commit().await { 404 + error!("Error committing reactivation: {:?}", e); 405 + return ( 406 + StatusCode::INTERNAL_SERVER_ERROR, 407 + Json(json!({"error": "InternalError"})), 408 + ) 409 + .into_response(); 410 + } 411 + let key_row: Option<(Vec<u8>, i32)> = sqlx::query_as( 412 + "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", 413 + ) 414 + .bind(account_id) 415 + .fetch_optional(&state.db) 416 + .await 417 + .unwrap_or(None); 418 + let secret_key_bytes = match key_row { 419 + Some((key_bytes, encryption_version)) => { 420 + match crate::config::decrypt_key(&key_bytes, Some(encryption_version)) { 421 + Ok(k) => k, 422 + Err(e) => { 423 + error!("Error decrypting key for reactivated account: {:?}", e); 424 + return ( 425 + StatusCode::INTERNAL_SERVER_ERROR, 426 + Json(json!({"error": "InternalError"})), 427 + ) 428 + .into_response(); 429 + } 430 + } 431 + } 432 + None => { 433 + error!("No signing key found for reactivated account"); 434 + return ( 435 + StatusCode::INTERNAL_SERVER_ERROR, 436 + Json(json!({"error": "InternalError", "message": "Account signing key not found"})), 437 + ) 438 + .into_response(); 439 + } 440 + }; 441 + let access_meta = match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) { 442 + Ok(m) => m, 443 + Err(e) => { 444 + error!("Error creating access token: {:?}", e); 445 + return ( 446 + StatusCode::INTERNAL_SERVER_ERROR, 447 + Json(json!({"error": "InternalError"})), 448 + ) 449 + .into_response(); 450 + } 451 + }; 452 + let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) { 453 + Ok(m) => m, 454 + Err(e) => { 455 + error!("Error creating refresh token: {:?}", e); 456 + return ( 457 + StatusCode::INTERNAL_SERVER_ERROR, 458 + Json(json!({"error": "InternalError"})), 459 + ) 460 + .into_response(); 461 + } 462 + }; 463 + let session_result: Result<_, sqlx::Error> = sqlx::query( 464 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 465 + ) 466 + .bind(&did) 467 + .bind(&access_meta.jti) 468 + .bind(&refresh_meta.jti) 469 + .bind(access_meta.expires_at) 470 + .bind(refresh_meta.expires_at) 471 + .execute(&state.db) 472 + .await; 473 + if let Err(e) = session_result { 474 + error!("Error creating session: {:?}", e); 475 + return ( 476 + StatusCode::INTERNAL_SERVER_ERROR, 477 + Json(json!({"error": "InternalError"})), 478 + ) 479 + .into_response(); 480 + } 481 + return ( 482 + StatusCode::OK, 483 + Json(CreateAccountOutput { 484 + handle: full_handle.clone(), 485 + did, 486 + access_jwt: Some(access_meta.token), 487 + refresh_jwt: Some(refresh_meta.token), 488 + verification_required: false, 489 + verification_channel: "email".to_string(), 490 + }), 491 + ) 492 + .into_response(); 493 + } else { 494 + return ( 495 + StatusCode::BAD_REQUEST, 496 + Json(json!({"error": "AccountAlreadyExists", "message": "An active account with this DID already exists"})), 497 + ) 498 + .into_response(); 499 + } 385 500 } 386 - Ok(None) => {} 501 + } 502 + let exists_result: Option<(i32,)> = sqlx::query_as( 503 + "SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL", 504 + ) 505 + .bind(short_handle) 506 + .fetch_optional(&mut *tx) 507 + .await 508 + .unwrap_or(None); 509 + if exists_result.is_some() { 510 + return ( 511 + StatusCode::BAD_REQUEST, 512 + Json(json!({"error": "HandleTaken", "message": "Handle already taken"})), 513 + ) 514 + .into_response(); 387 515 } 388 516 let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") 389 517 .map(|v| v == "true" || v == "1")
+1
src/api/repo/blob.rs
··· 222 222 223 223 #[derive(Serialize)] 224 224 pub struct ListMissingBlobsOutput { 225 + #[serde(skip_serializing_if = "Option::is_none")] 225 226 pub cursor: Option<String>, 226 227 pub blobs: Vec<RecordBlob>, 227 228 }
+1
src/api/repo/record/read.rs
··· 197 197 } 198 198 #[derive(Serialize)] 199 199 pub struct ListRecordsOutput { 200 + #[serde(skip_serializing_if = "Option::is_none")] 200 201 pub cursor: Option<String>, 201 202 pub records: Vec<serde_json::Value>, 202 203 }
+1
src/sync/blob.rs
··· 110 110 111 111 #[derive(Serialize)] 112 112 pub struct ListBlobsOutput { 113 + #[serde(skip_serializing_if = "Option::is_none")] 113 114 pub cursor: Option<String>, 114 115 pub cids: Vec<String>, 115 116 }
+1
src/sync/commit.rs
··· 101 101 102 102 #[derive(Serialize)] 103 103 pub struct ListReposOutput { 104 + #[serde(skip_serializing_if = "Option::is_none")] 104 105 pub cursor: Option<String>, 105 106 pub repos: Vec<RepoInfo>, 106 107 }