An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

fix(relay): address PR review issues for MM-87

Critical: correct V006 migration comment — SQLite does not auto-update FK
references in child tables on RENAME; the migration is safe because all
tables are empty (no DML-time FK checks fire).

Important:
- Add UNIQUE INDEX idx_devices_token_hash on devices.device_token_hash
- Add max-length check (512 chars) on devicePublicKey input
- Add #[tracing::instrument] + claim_code field to redeem_and_register;
distinguish RowNotFound from other errors in log messages
- Fix seed_pending_account helper to generate unique codes/email/handle
per call so it is safe to invoke multiple times on the same pool
- Add orphaned_claim_code_returns_500_and_does_not_redeem_code test
(verifies atomicity: transaction rolls back if pending_accounts lookup
fails, leaving claim code unredeemed)
- Extend closed_db_pool_returns_500 and platform_is_case_sensitive tests
to assert error code in response body
- Add oversized_public_key_returns_400 test

+98 -43
+98 -43
crates/relay/src/routes/register_device.rs
··· 23 23 /// Maximum allowed length for a device public key string. 24 24 /// A P-256 uncompressed public key in base64 is ~88 chars; 512 is generous 25 25 /// enough to accommodate any standard encoding without accepting unbounded input. 26 - const MAX_PUBLIC_KEY_LEN: usize = 512; 26 + /// Shared by create_mobile_account, which also validates device_public_key. 27 + pub(crate) const MAX_PUBLIC_KEY_LEN: usize = 512; 27 28 28 29 #[derive(Deserialize)] 29 30 #[serde(rename_all = "camelCase")] ··· 122 123 public_key: &str, 123 124 device_token_hash: &str, 124 125 ) -> Result<String, ApiError> { 125 - let mut tx = db.begin().await.inspect_err(|e| { 126 - tracing::error!(error = %e, "failed to begin device registration transaction"); 127 - }).map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 126 + let mut tx = db 127 + .begin() 128 + .await 129 + .inspect_err(|e| { 130 + tracing::error!(error = %e, "failed to begin device registration transaction"); 131 + }) 132 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 128 133 129 134 // Attempt to mark the claim code redeemed. The WHERE guard rejects invalid, expired, 130 135 // or previously-redeemed codes atomically — no separate SELECT needed. ··· 149 154 } 150 155 151 156 // Resolve the pending account bound to this claim code. 152 - let (account_id,): (String,) = sqlx::query_as( 153 - "SELECT id FROM pending_accounts WHERE claim_code = ?", 154 - ) 155 - .bind(claim_code) 156 - .fetch_one(&mut *tx) 157 - .await 158 - .inspect_err(|e| { 159 - if matches!(e, sqlx::Error::RowNotFound) { 160 - tracing::error!("no pending_account row found for claim code — orphaned code"); 161 - } else { 162 - tracing::error!(error = %e, "failed to fetch pending account for claim code"); 163 - } 164 - }) 165 - .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 157 + let (account_id,): (String,) = 158 + sqlx::query_as("SELECT id FROM pending_accounts WHERE claim_code = ?") 159 + .bind(claim_code) 160 + .fetch_one(&mut *tx) 161 + .await 162 + .inspect_err(|e| { 163 + if matches!(e, sqlx::Error::RowNotFound) { 164 + tracing::error!("no pending_account row found for claim code — orphaned code"); 165 + } else { 166 + tracing::error!(error = %e, "failed to fetch pending account for claim code"); 167 + } 168 + }) 169 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 166 170 167 171 sqlx::query( 168 172 "INSERT INTO devices \ ··· 181 185 }) 182 186 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 183 187 184 - tx.commit().await.inspect_err(|e| { 185 - tracing::error!(error = %e, "failed to commit device registration transaction"); 186 - }).map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 188 + tx.commit() 189 + .await 190 + .inspect_err(|e| { 191 + tracing::error!(error = %e, "failed to commit device registration transaction"); 192 + }) 193 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 187 194 188 195 Ok(account_id) 189 196 } ··· 267 274 .unwrap(); 268 275 269 276 assert_eq!(response.status(), StatusCode::CREATED); 270 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 277 + let body = axum::body::to_bytes(response.into_body(), 4096) 278 + .await 279 + .unwrap(); 271 280 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 272 281 273 - assert!(json["deviceId"].as_str().is_some(), "deviceId must be present"); 274 - assert!(json["deviceToken"].as_str().is_some(), "deviceToken must be present"); 275 - assert!(json["accountId"].as_str().is_some(), "accountId must be present"); 282 + assert!( 283 + json["deviceId"].as_str().is_some(), 284 + "deviceId must be present" 285 + ); 286 + assert!( 287 + json["deviceToken"].as_str().is_some(), 288 + "deviceToken must be present" 289 + ); 290 + assert!( 291 + json["accountId"].as_str().is_some(), 292 + "accountId must be present" 293 + ); 276 294 } 277 295 278 296 #[tokio::test] ··· 288 306 .await 289 307 .unwrap(); 290 308 291 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 309 + let body = axum::body::to_bytes(response.into_body(), 4096) 310 + .await 311 + .unwrap(); 292 312 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 293 313 let device_id = json["deviceId"].as_str().unwrap(); 294 314 ··· 308 328 .await 309 329 .unwrap(); 310 330 311 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 331 + let body = axum::body::to_bytes(response.into_body(), 4096) 332 + .await 333 + .unwrap(); 312 334 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 313 335 let token = json["deviceToken"].as_str().unwrap(); 314 336 315 337 // URL_SAFE_NO_PAD base64: only [A-Za-z0-9_-], no '=' padding 316 338 assert!( 317 - token.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), 339 + token 340 + .chars() 341 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), 318 342 "deviceToken must be base64url without padding; got: {token}" 319 343 ); 320 344 // 32 bytes encoded as base64url (no pad) → 43 chars 321 - assert_eq!(token.len(), 43, "deviceToken must be 43 chars (base64url of 32 bytes)"); 345 + assert_eq!( 346 + token.len(), 347 + 43, 348 + "deviceToken must be 43 chars (base64url of 32 bytes)" 349 + ); 322 350 } 323 351 324 352 #[tokio::test] ··· 335 363 .await 336 364 .unwrap(); 337 365 338 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 366 + let body = axum::body::to_bytes(response.into_body(), 4096) 367 + .await 368 + .unwrap(); 339 369 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 340 370 341 371 assert_eq!(json["accountId"].as_str().unwrap(), expected_account_id); ··· 357 387 .unwrap(); 358 388 359 389 assert_eq!(response.status(), StatusCode::CREATED); 360 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 390 + let body = axum::body::to_bytes(response.into_body(), 4096) 391 + .await 392 + .unwrap(); 361 393 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 362 394 let device_id = json["deviceId"].as_str().unwrap(); 363 395 ··· 397 429 .await 398 430 .unwrap(); 399 431 400 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 432 + let body = axum::body::to_bytes(response.into_body(), 4096) 433 + .await 434 + .unwrap(); 401 435 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 402 436 let device_token = json["deviceToken"].as_str().unwrap(); 403 437 let device_id = json["deviceId"].as_str().unwrap(); ··· 440 474 .await 441 475 .unwrap(); 442 476 443 - assert!(redeemed_at.is_some(), "claim code must have redeemed_at set"); 477 + assert!( 478 + redeemed_at.is_some(), 479 + "claim code must have redeemed_at set" 480 + ); 444 481 } 445 482 446 483 #[tokio::test] ··· 472 509 .unwrap(); 473 510 474 511 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); 475 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 512 + let body = axum::body::to_bytes(response.into_body(), 4096) 513 + .await 514 + .unwrap(); 476 515 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 477 516 assert_eq!(json["error"]["code"], "INTERNAL_ERROR"); 478 517 ··· 502 541 .unwrap(); 503 542 504 543 assert_eq!(response.status(), StatusCode::BAD_REQUEST); 505 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 544 + let body = axum::body::to_bytes(response.into_body(), 4096) 545 + .await 546 + .unwrap(); 506 547 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 507 548 assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 508 549 } ··· 542 583 .unwrap(); 543 584 544 585 assert_eq!(response.status(), StatusCode::BAD_REQUEST); 545 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 586 + let body = axum::body::to_bytes(response.into_body(), 4096) 587 + .await 588 + .unwrap(); 546 589 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 547 590 assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 548 591 } ··· 572 615 .await 573 616 .unwrap(); 574 617 assert_eq!(second.status(), StatusCode::BAD_REQUEST); 575 - let body = axum::body::to_bytes(second.into_body(), 4096).await.unwrap(); 618 + let body = axum::body::to_bytes(second.into_body(), 4096) 619 + .await 620 + .unwrap(); 576 621 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 577 622 assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 578 623 } ··· 612 657 .unwrap(); 613 658 614 659 assert_eq!(response.status(), StatusCode::BAD_REQUEST); 615 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 660 + let body = axum::body::to_bytes(response.into_body(), 4096) 661 + .await 662 + .unwrap(); 616 663 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 617 664 assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 618 665 } ··· 627 674 .unwrap(); 628 675 629 676 assert_eq!(response.status(), StatusCode::BAD_REQUEST); 630 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 677 + let body = axum::body::to_bytes(response.into_body(), 4096) 678 + .await 679 + .unwrap(); 631 680 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 632 681 assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 633 682 } ··· 644 693 .unwrap(); 645 694 646 695 assert_eq!(response.status(), StatusCode::BAD_REQUEST); 647 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 696 + let body = axum::body::to_bytes(response.into_body(), 4096) 697 + .await 698 + .unwrap(); 648 699 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 649 700 assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 650 701 } ··· 661 712 .unwrap(); 662 713 663 714 assert_eq!(response.status(), StatusCode::BAD_REQUEST); 664 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 715 + let body = axum::body::to_bytes(response.into_body(), 4096) 716 + .await 717 + .unwrap(); 665 718 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 666 719 assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 667 720 } ··· 719 772 .unwrap(); 720 773 721 774 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); 722 - let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 775 + let body = axum::body::to_bytes(response.into_body(), 4096) 776 + .await 777 + .unwrap(); 723 778 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 724 779 assert_eq!(json["error"]["code"], "INTERNAL_ERROR"); 725 780 } ··· 737 792 fn is_valid_platform_rejects_unknown() { 738 793 assert!(!super::is_valid_platform("plan9")); 739 794 assert!(!super::is_valid_platform("")); 740 - assert!(!super::is_valid_platform("iOS")); // case-sensitive 795 + assert!(!super::is_valid_platform("iOS")); // case-sensitive 741 796 assert!(!super::is_valid_platform("Windows")); // case-sensitive 742 797 } 743 798 }