An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.

refactor: extract shared token, constraint, and uniqueness helpers #32

merged opened by malpercio.dev targeting main from refactor/relay-dry-extraction

Summary

  • Extract routes::token module — single source of truth for token generation + SHA-256 hashing (was copy-pasted 10+ times)
  • Centralize is_unique_violation() / unique_violation_column() in db module (was 4 divergent implementations)
  • Extract routes::uniqueness module for email/handle pre-flight queries (was duplicated across 2 handlers)
  • Replace string-based is_valid_platform() with a serde-deserializable Platform enum
  • Extract base32_lowercase() helper in crypto crate
  • Break up 270-line create_did_handler into 45-line orchestrator + 6 focused helpers
  • Remove AC-prefixed test comments per project convention

Test plan

  • cargo test — all 328 tests pass (including 7 new token module tests)
  • cargo clippy --workspace -- -D warnings — clean
  • cargo fmt --all --check — clean
  • No behavior changes — all existing API contracts preserved
  • Invalid platform now returns 422 (serde rejection) instead of 400 (application validation) — more semantically correct
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:web:malpercio.dev/sh.tangled.repo.pull/3mh7gv7g7d222
+669 -505
Diff #0
+11 -14
crates/crypto/src/plc.rs
··· 149 149 /// # Errors 150 150 /// Returns `CryptoError::PlcOperation` if `signing_private_key` is not a valid P-256 scalar 151 151 /// (e.g. all-zero bytes, or a value ≥ the curve order). 152 + /// RFC 4648 base32 with lowercase symbols (a-z, 2-7). 153 + /// Used by the did:plc spec to derive the DID from a SHA-256 hash. 154 + fn base32_lowercase() -> Result<data_encoding::Encoding, CryptoError> { 155 + let mut spec = data_encoding::Specification::new(); 156 + spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567"); 157 + spec.encoding() 158 + .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}"))) 159 + } 160 + 152 161 pub fn build_did_plc_genesis_op( 153 162 rotation_key: &DidKeyUri, 154 163 signing_key: &DidKeyUri, ··· 216 225 let hash = Sha256::digest(&signed_cbor); 217 226 218 227 // Step 9: base32-lowercase, take first 24 characters. 219 - let base32_encoding = { 220 - let mut spec = data_encoding::Specification::new(); 221 - spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567"); 222 - spec.encoding() 223 - .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}")))? 224 - }; 225 - let encoded = base32_encoding.encode(hash.as_ref()); 228 + let encoded = base32_lowercase()?.encode(hash.as_ref()); 226 229 let did = format!("did:plc:{}", &encoded[..24]); 227 230 228 231 // Step 10: JSON-serialize the signed operation. ··· 330 333 .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?; 331 334 332 335 let hash = Sha256::digest(&signed_cbor); 333 - let base32_encoding = { 334 - let mut spec = data_encoding::Specification::new(); 335 - spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567"); 336 - spec.encoding() 337 - .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}")))? 338 - }; 339 - let encoded = base32_encoding.encode(hash.as_ref()); 336 + let encoded = base32_lowercase()?.encode(hash.as_ref()); 340 337 let did = format!("did:plc:{}", &encoded[..24]); 341 338 342 339 // Step 10: Extract atproto_pds endpoint from services map.
+3 -3
crates/crypto/src/shamir.rs
··· 168 168 mod tests { 169 169 use super::*; 170 170 171 - // ── AC: Splitting a 32-byte secret produces 3 shares ───────────────────── 171 + // ── Splitting a 32-byte secret produces 3 shares ────────────────────────── 172 172 173 173 #[test] 174 174 fn split_shares_have_correct_indices() { ··· 188 188 } 189 189 } 190 190 191 - // ── AC: Any 2 shares reconstruct the original secret ───────────────────── 191 + // ── Any 2 shares reconstruct the original secret ────────────────────────── 192 192 193 193 #[test] 194 194 fn combine_shares_1_and_2_reconstructs_secret() { ··· 252 252 } 253 253 } 254 254 255 - // ── AC: Single share reveals nothing ───────────────────────────────────── 255 + // ── Single share reveals nothing ─────────────────────────────────────────── 256 256 257 257 /// Sanity check: with overwhelming probability, share data ≠ plaintext. 258 258 /// (Not a proof of information-theoretic security; the math guarantees that.)
+28
crates/relay/src/db/mod.rs
··· 183 183 Ok(()) 184 184 } 185 185 186 + // ── Unique constraint helpers ───────────────────────────────────────────── 187 + 188 + /// Check if a sqlx::Error is a UNIQUE constraint violation. 189 + pub fn is_unique_violation(e: &sqlx::Error) -> bool { 190 + matches!( 191 + e, 192 + sqlx::Error::Database(db_err) 193 + if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation 194 + ) 195 + } 196 + 197 + /// Extract the column name from a UNIQUE constraint violation on a specific table. 198 + /// 199 + /// SQLite's stable error format is `"UNIQUE constraint failed: <table>.<column>"`. 200 + /// Returns `Some(column)` if the error matches, `None` otherwise. 201 + pub fn unique_violation_column<'a>(e: &'a sqlx::Error, table: &str) -> Option<&'a str> { 202 + if let sqlx::Error::Database(db_err) = e { 203 + if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation { 204 + let prefix = format!("UNIQUE constraint failed: {table}."); 205 + let msg = db_err.message(); 206 + if let Some(column) = msg.strip_prefix(&prefix) { 207 + return Some(column); 208 + } 209 + } 210 + } 211 + None 212 + } 213 + 186 214 #[cfg(test)] 187 215 mod tests { 188 216 use super::*;
+20 -67
crates/relay/src/routes/auth.rs
··· 81 81 headers: &HeaderMap, 82 82 db: &sqlx::SqlitePool, 83 83 ) -> Result<PendingSessionInfo, ApiError> { 84 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 85 - use sha2::{Digest, Sha256}; 84 + use crate::routes::token::hash_bearer_token; 86 85 87 86 // Extract Bearer token from Authorization header. 88 87 let token = headers ··· 106 105 107 106 // Decode base64url → raw bytes, then SHA-256 hash → hex string. 108 107 // Matches the storage format written by POST /v1/accounts/mobile. 109 - let token_bytes = URL_SAFE_NO_PAD 110 - .decode(token) 111 - .map_err(|_| ApiError::new(ErrorCode::Unauthorized, "invalid session token"))?; 112 - let token_hash: String = Sha256::digest(&token_bytes) 113 - .iter() 114 - .map(|b| format!("{b:02x}")) 115 - .collect(); 108 + let token_hash = hash_bearer_token(token)?; 116 109 117 110 // Look up the session by hash, rejecting expired sessions. 118 111 let row: Option<(String, String)> = sqlx::query_as( ··· 152 145 headers: &HeaderMap, 153 146 db: &sqlx::SqlitePool, 154 147 ) -> Result<SessionInfo, ApiError> { 155 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 156 - use sha2::{Digest, Sha256}; 148 + use crate::routes::token::hash_bearer_token; 157 149 158 150 let token = headers 159 151 .get(axum::http::header::AUTHORIZATION) ··· 174 166 ) 175 167 })?; 176 168 177 - let token_bytes = URL_SAFE_NO_PAD.decode(token).map_err(|_| { 178 - tracing::debug!("session token is not valid base64url"); 179 - ApiError::new(ErrorCode::Unauthorized, "invalid session token") 180 - })?; 181 - let token_hash: String = Sha256::digest(&token_bytes) 182 - .iter() 183 - .map(|b| format!("{b:02x}")) 184 - .collect(); 169 + let token_hash = hash_bearer_token(token)?; 185 170 186 171 let row: Option<(String,)> = sqlx::query_as( 187 172 "SELECT did FROM sessions WHERE token_hash = ? AND expires_at > datetime('now')", ··· 311 296 312 297 #[tokio::test] 313 298 async fn pending_session_valid_unexpired_session_returns_ok() { 314 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 315 - use rand_core::{OsRng, RngCore}; 316 - use sha2::{Digest, Sha256}; 299 + use crate::routes::token::generate_token; 317 300 use uuid::Uuid; 318 301 319 302 let state = test_state().await; ··· 356 339 .expect("insert device"); 357 340 358 341 // Generate a valid session token. 359 - let mut token_bytes = [0u8; 32]; 360 - OsRng.fill_bytes(&mut token_bytes); 361 - let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 362 - let token_hash: String = Sha256::digest(token_bytes) 363 - .iter() 364 - .map(|b| format!("{b:02x}")) 365 - .collect(); 342 + let token = generate_token(); 366 343 367 344 sqlx::query( 368 345 "INSERT INTO pending_sessions \ ··· 372 349 .bind(Uuid::new_v4().to_string()) 373 350 .bind(&account_id) 374 351 .bind(&device_id) 375 - .bind(&token_hash) 352 + .bind(&token.hash) 376 353 .execute(&state.db) 377 354 .await 378 355 .expect("insert pending_session"); ··· 381 358 let mut headers = HeaderMap::new(); 382 359 headers.insert( 383 360 axum::http::header::AUTHORIZATION, 384 - format!("Bearer {session_token}").parse().unwrap(), 361 + format!("Bearer {}", token.plaintext).parse().unwrap(), 385 362 ); 386 363 387 364 let result = require_pending_session(&headers, &state.db) ··· 393 370 394 371 #[tokio::test] 395 372 async fn pending_session_expired_session_returns_401() { 396 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 397 - use rand_core::{OsRng, RngCore}; 398 - use sha2::{Digest, Sha256}; 373 + use crate::routes::token::generate_token; 399 374 use uuid::Uuid; 400 375 401 376 let state = test_state().await; ··· 438 413 .expect("insert device"); 439 414 440 415 // Generate a token but set it as expired. 441 - let mut token_bytes = [0u8; 32]; 442 - OsRng.fill_bytes(&mut token_bytes); 443 - let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 444 - let token_hash: String = Sha256::digest(token_bytes) 445 - .iter() 446 - .map(|b| format!("{b:02x}")) 447 - .collect(); 416 + let token = generate_token(); 448 417 449 418 sqlx::query( 450 419 "INSERT INTO pending_sessions \ ··· 454 423 .bind(Uuid::new_v4().to_string()) 455 424 .bind(&account_id) 456 425 .bind(&device_id) 457 - .bind(&token_hash) 426 + .bind(&token.hash) 458 427 .execute(&state.db) 459 428 .await 460 429 .expect("insert pending_session"); ··· 463 432 let mut headers = HeaderMap::new(); 464 433 headers.insert( 465 434 axum::http::header::AUTHORIZATION, 466 - format!("Bearer {session_token}").parse().unwrap(), 435 + format!("Bearer {}", token.plaintext).parse().unwrap(), 467 436 ); 468 437 469 438 let err = require_pending_session(&headers, &state.db) ··· 513 482 514 483 #[tokio::test] 515 484 async fn session_valid_unexpired_session_returns_ok() { 516 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 517 - use rand_core::{OsRng, RngCore}; 518 - use sha2::{Digest, Sha256}; 485 + use crate::routes::token::generate_token; 519 486 use uuid::Uuid; 520 487 521 488 let state = test_state().await; ··· 535 502 .await 536 503 .expect("insert account"); 537 504 538 - let mut token_bytes = [0u8; 32]; 539 - OsRng.fill_bytes(&mut token_bytes); 540 - let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 541 - let token_hash: String = Sha256::digest(token_bytes) 542 - .iter() 543 - .map(|b| format!("{b:02x}")) 544 - .collect(); 505 + let token = generate_token(); 545 506 546 507 sqlx::query( 547 508 "INSERT INTO sessions (id, did, device_id, token_hash, created_at, expires_at) \ ··· 549 510 ) 550 511 .bind(Uuid::new_v4().to_string()) 551 512 .bind(&did) 552 - .bind(&token_hash) 513 + .bind(&token.hash) 553 514 .execute(&state.db) 554 515 .await 555 516 .expect("insert session"); ··· 557 518 let mut headers = HeaderMap::new(); 558 519 headers.insert( 559 520 axum::http::header::AUTHORIZATION, 560 - format!("Bearer {session_token}").parse().unwrap(), 521 + format!("Bearer {}", token.plaintext).parse().unwrap(), 561 522 ); 562 523 563 524 let result = require_session(&headers, &state.db) ··· 568 529 569 530 #[tokio::test] 570 531 async fn session_expired_session_returns_401() { 571 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 572 - use rand_core::{OsRng, RngCore}; 573 - use sha2::{Digest, Sha256}; 532 + use crate::routes::token::generate_token; 574 533 use uuid::Uuid; 575 534 576 535 let state = test_state().await; ··· 590 549 .await 591 550 .expect("insert account"); 592 551 593 - let mut token_bytes = [0u8; 32]; 594 - OsRng.fill_bytes(&mut token_bytes); 595 - let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 596 - let token_hash: String = Sha256::digest(token_bytes) 597 - .iter() 598 - .map(|b| format!("{b:02x}")) 599 - .collect(); 552 + let token = generate_token(); 600 553 601 554 sqlx::query( 602 555 "INSERT INTO sessions (id, did, device_id, token_hash, created_at, expires_at) \ ··· 604 557 ) 605 558 .bind(Uuid::new_v4().to_string()) 606 559 .bind(&did) 607 - .bind(&token_hash) 560 + .bind(&token.hash) 608 561 .execute(&state.db) 609 562 .await 610 563 .expect("insert expired session"); ··· 612 565 let mut headers = HeaderMap::new(); 613 566 headers.insert( 614 567 axum::http::header::AUTHORIZATION, 615 - format!("Bearer {session_token}").parse().unwrap(), 568 + format!("Bearer {}", token.plaintext).parse().unwrap(), 616 569 ); 617 570 618 571 let err = require_session(&headers, &state.db).await.unwrap_err();
+1 -8
crates/relay/src/routes/claim_codes.rs
··· 10 10 use common::{ApiError, ErrorCode}; 11 11 12 12 use crate::app::AppState; 13 + use crate::db::is_unique_violation; 13 14 use crate::routes::auth::require_admin_token; 14 15 use crate::routes::code_gen::generate_code; 15 16 ··· 118 119 Ok(()) 119 120 } 120 121 121 - fn is_unique_violation(e: &sqlx::Error) -> bool { 122 - matches!( 123 - e, 124 - sqlx::Error::Database(db_err) 125 - if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation 126 - ) 127 - } 128 - 129 122 #[cfg(test)] 130 123 mod tests { 131 124 use axum::{
+48 -100
crates/relay/src/routes/create_account.rs
··· 60 60 )); 61 61 } 62 62 63 - // --- Email uniqueness: check accounts and pending_accounts in one query --- 64 - // Fast-path: reject before the INSERT to avoid burning a claim_code slot on a 65 - // predictable conflict. The unique indexes are the authoritative enforcement; this 66 - // is an optimization that also provides an early error for fully-provisioned accounts. 67 - // Note: pending_accounts has no cross-table FK to accounts, so both tables must be checked. 68 - // CAST ensures sqlx maps the result as INTEGER regardless of SQLite's type affinity 69 - // rules on untyped OR expressions. 70 - let email_taken: i64 = sqlx::query_scalar( 71 - "SELECT CAST( 72 - (EXISTS(SELECT 1 FROM accounts WHERE email = ?) 73 - OR EXISTS(SELECT 1 FROM pending_accounts WHERE email = ?)) 74 - AS INTEGER)", 75 - ) 76 - .bind(&payload.email) 77 - .bind(&payload.email) 78 - .fetch_one(&state.db) 79 - .await 80 - .map_err(|e| { 81 - tracing::error!(error = %e, "failed to check email uniqueness"); 82 - ApiError::new(ErrorCode::InternalError, "failed to create account") 83 - })?; 84 - 85 - if email_taken != 0 { 63 + // --- Email uniqueness: fast-path rejection before INSERT --- 64 + if crate::routes::uniqueness::email_taken(&state.db, &payload.email) 65 + .await 66 + .map_err(|e| { 67 + tracing::error!(error = %e, "failed to check email uniqueness"); 68 + ApiError::new(ErrorCode::InternalError, "failed to create account") 69 + })? 70 + { 86 71 return Err(ApiError::new( 87 72 ErrorCode::AccountExists, 88 73 "an account with this email already exists", 89 74 )); 90 75 } 91 76 92 - // --- Handle uniqueness: check handles and pending_accounts in one query --- 93 - // handles.handle is the PRIMARY KEY (uniqueness enforced by the PK, not a separate index). 94 - let handle_taken: i64 = sqlx::query_scalar( 95 - "SELECT CAST( 96 - (EXISTS(SELECT 1 FROM handles WHERE handle = ?) 97 - OR EXISTS(SELECT 1 FROM pending_accounts WHERE handle = ?)) 98 - AS INTEGER)", 99 - ) 100 - .bind(&payload.handle) 101 - .bind(&payload.handle) 102 - .fetch_one(&state.db) 103 - .await 104 - .map_err(|e| { 105 - tracing::error!(error = %e, "failed to check handle uniqueness"); 106 - ApiError::new(ErrorCode::InternalError, "failed to create account") 107 - })?; 108 - 109 - if handle_taken != 0 { 77 + // --- Handle uniqueness: fast-path rejection before INSERT --- 78 + if crate::routes::uniqueness::handle_taken(&state.db, &payload.handle) 79 + .await 80 + .map_err(|e| { 81 + tracing::error!(error = %e, "failed to check handle uniqueness"); 82 + ApiError::new(ErrorCode::InternalError, "failed to create account") 83 + })? 84 + { 110 85 return Err(ApiError::new( 111 86 ErrorCode::HandleTaken, 112 87 "this handle is already claimed", ··· 142 117 }), 143 118 )) 144 119 } 145 - Err(e) => match unique_violation_source(&e) { 146 - Some(UniqueConflict::ClaimCode) => { 147 - tracing::warn!(attempt, "claim code collision; retrying"); 148 - continue; 149 - } 150 - Some(UniqueConflict::Email) => { 151 - return Err(ApiError::new( 152 - ErrorCode::AccountExists, 153 - "an account with this email already exists", 154 - )); 120 + Err(e) if crate::db::is_unique_violation(&e) => { 121 + match unique_violation_column_in_pending(&e) { 122 + Some("email") => { 123 + return Err(ApiError::new( 124 + ErrorCode::AccountExists, 125 + "an account with this email already exists", 126 + )); 127 + } 128 + Some("handle") => { 129 + return Err(ApiError::new( 130 + ErrorCode::HandleTaken, 131 + "this handle is already claimed", 132 + )); 133 + } 134 + _ => { 135 + // Not a pending_accounts constraint — treat as claim code collision. 136 + tracing::warn!(attempt, "claim code collision; retrying"); 137 + continue; 138 + } 155 139 } 156 - Some(UniqueConflict::Handle) => { 157 - return Err(ApiError::new( 158 - ErrorCode::HandleTaken, 159 - "this handle is already claimed", 160 - )); 161 - } 162 - None => { 163 - tracing::error!(error = %e, "failed to insert pending account"); 164 - return Err(ApiError::new( 165 - ErrorCode::InternalError, 166 - "failed to create account", 167 - )); 168 - } 169 - }, 140 + } 141 + Err(e) => { 142 + tracing::error!(error = %e, "failed to insert pending account"); 143 + return Err(ApiError::new( 144 + ErrorCode::InternalError, 145 + "failed to create account", 146 + )); 147 + } 170 148 } 171 149 } 172 150 ··· 249 227 Ok(()) 250 228 } 251 229 252 - /// Classification of a unique constraint violation by which column fired. 253 - enum UniqueConflict { 254 - /// `claim_codes.code` — safe to retry with a freshly generated code. 255 - ClaimCode, 256 - /// `pending_accounts.email` — return `AccountExists` immediately. 257 - Email, 258 - /// `pending_accounts.handle` — return `HandleTaken` immediately. 259 - Handle, 260 - } 261 - 262 - /// Inspect a unique constraint violation to determine which column caused it. 263 - /// Returns `None` for non-unique-violation errors. 264 - /// 265 - /// SQLite unique violation messages take the stable form 266 - /// "UNIQUE constraint failed: <table>.<column>", which is not locale-dependent. 267 - fn unique_violation_source(e: &sqlx::Error) -> Option<UniqueConflict> { 268 - if let sqlx::Error::Database(db_err) = e { 269 - if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation { 270 - let msg = db_err.message(); 271 - if msg.contains("pending_accounts.email") { 272 - return Some(UniqueConflict::Email); 273 - } 274 - if msg.contains("pending_accounts.handle") { 275 - return Some(UniqueConflict::Handle); 276 - } 277 - // Treat any other unique violation as a claim_codes.code collision. 278 - // Log the constraint name so unexpected constraints are visible in traces. 279 - tracing::debug!( 280 - constraint = msg, 281 - "unique violation on unknown constraint; treating as claim code collision" 282 - ); 283 - return Some(UniqueConflict::ClaimCode); 284 - } 285 - } 286 - None 230 + /// Classify a unique violation from the transaction (which spans claim_codes and 231 + /// pending_accounts). Returns `Some("email")` or `Some("handle")` for pending_accounts 232 + /// violations, `None` (treated as claim_code collision) for everything else. 233 + fn unique_violation_column_in_pending(e: &sqlx::Error) -> Option<&str> { 234 + crate::db::unique_violation_column(e, "pending_accounts") 287 235 } 288 236 289 237 #[cfg(test)]
+175 -129
crates/relay/src/routes/create_did.rs
··· 42 42 // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 43 43 44 44 use axum::{extract::State, http::HeaderMap, Json}; 45 - use rand_core::RngCore; 46 45 use serde::{Deserialize, Serialize}; 47 46 48 47 use crate::app::AppState; 48 + use crate::db::is_unique_violation; 49 49 use crate::routes::auth::require_pending_session; 50 + use crate::routes::token::generate_token; 50 51 use common::{ApiError, ErrorCode}; 51 52 52 - /// Check if a sqlx::Error is a UNIQUE constraint violation. 53 - fn is_unique_violation(e: &sqlx::Error) -> bool { 54 - matches!( 55 - e, 56 - sqlx::Error::Database(db_err) 57 - if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation 58 - ) 59 - } 60 - 61 53 #[derive(Deserialize)] 62 54 #[serde(rename_all = "camelCase")] 63 55 pub struct CreateDidRequest { ··· 78 70 headers: HeaderMap, 79 71 Json(payload): Json<CreateDidRequest>, 80 72 ) -> Result<Json<CreateDidResponse>, ApiError> { 81 - // Step 1: Authenticate via pending_session Bearer token. 73 + // Phase 1: Authenticate and load pending account. 82 74 let session = require_pending_session(&headers, &state.db).await?; 75 + let pending = load_pending_account(&state.db, &session.account_id).await?; 83 76 84 - // Step 2: Load pending account details. 77 + // Phase 2: Verify the genesis op and validate it against account + server config. 78 + let (verified, signed_op_str) = 79 + verify_and_validate_genesis_op(&payload, &pending.handle, &state.config.public_url)?; 80 + let did = &verified.did; 81 + 82 + // Phase 3: Pre-store DID for retry resilience, then POST to plc.directory. 83 + let skip_plc = pre_store_did(&state.db, &session.account_id, did, &pending.pending_did).await?; 84 + check_already_promoted(&state.db, did).await?; 85 + if !skip_plc { 86 + post_to_plc_directory( 87 + &state.http_client, 88 + &state.config.plc_directory_url, 89 + did, 90 + &signed_op_str, 91 + ) 92 + .await?; 93 + } 94 + 95 + // Phase 4: Build DID document, generate session, atomically promote. 96 + let did_document = build_did_document(&verified)?; 97 + let session_token = generate_token(); 98 + promote_account( 99 + &state.db, 100 + did, 101 + &pending.email, 102 + &session.account_id, 103 + &did_document, 104 + &session_token.hash, 105 + ) 106 + .await?; 107 + 108 + Ok(Json(CreateDidResponse { 109 + did: did.clone(), 110 + did_document, 111 + status: "active", 112 + session_token: session_token.plaintext, 113 + })) 114 + } 115 + 116 + // ── Phase helpers ───────────────────────────────────────────────────────────── 117 + 118 + struct PendingAccount { 119 + handle: String, 120 + pending_did: Option<String>, 121 + email: String, 122 + } 123 + 124 + /// Load pending account details (Step 2). 125 + async fn load_pending_account( 126 + db: &sqlx::SqlitePool, 127 + account_id: &str, 128 + ) -> Result<PendingAccount, ApiError> { 85 129 let (handle, pending_did, email): (String, Option<String>, String) = 86 130 sqlx::query_as("SELECT handle, pending_did, email FROM pending_accounts WHERE id = ?") 87 - .bind(&session.account_id) 88 - .fetch_optional(&state.db) 131 + .bind(account_id) 132 + .fetch_optional(db) 89 133 .await 90 134 .map_err(|e| { 91 135 tracing::error!(error = %e, "failed to query pending account"); 92 136 ApiError::new(ErrorCode::InternalError, "failed to load account") 93 137 })? 94 138 .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 139 + Ok(PendingAccount { 140 + handle, 141 + pending_did, 142 + email, 143 + }) 144 + } 95 145 146 + /// Validate the rotation key format, verify the genesis op signature, and check 147 + /// that the op fields match the account handle and server config (Steps 3-6). 148 + fn verify_and_validate_genesis_op( 149 + payload: &CreateDidRequest, 150 + handle: &str, 151 + public_url: &str, 152 + ) -> Result<(crypto::VerifiedGenesisOp, String), ApiError> { 96 153 // Step 3: Validate rotationKeyPublic format. 97 154 if !payload.rotation_key_public.starts_with("did:key:z") { 98 155 return Err(ApiError::new( ··· 139 196 "alsoKnownAs[0] in op does not match account handle", 140 197 )); 141 198 } 142 - if verified.atproto_pds_endpoint.as_deref() != Some(&state.config.public_url) { 199 + if verified.atproto_pds_endpoint.as_deref() != Some(public_url) { 143 200 return Err(ApiError::new( 144 201 ErrorCode::InvalidClaim, 145 202 "services.atproto_pds.endpoint in op does not match server public URL", 146 203 )); 147 204 } 148 205 149 - let did = &verified.did; 206 + Ok((verified, signed_op_str)) 207 + } 150 208 151 - // Step 7: Pre-store the DID for retry resilience. 152 - let skip_plc_directory = if let Some(pre_stored_did) = &pending_did { 209 + /// Pre-store the DID in pending_accounts for retry resilience (Step 7). 210 + /// 211 + /// Returns `true` if a previous attempt already stored this DID (skip plc.directory). 212 + async fn pre_store_did( 213 + db: &sqlx::SqlitePool, 214 + account_id: &str, 215 + did: &str, 216 + pending_did: &Option<String>, 217 + ) -> Result<bool, ApiError> { 218 + if let Some(pre_stored_did) = pending_did { 153 219 if did != pre_stored_did { 154 220 tracing::error!( 155 221 derived_did = %did, ··· 162 228 )); 163 229 } 164 230 tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, skipping plc.directory"); 165 - true 166 - } else { 167 - let result = sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 168 - .bind(did) 169 - .bind(&session.account_id) 170 - .execute(&state.db) 171 - .await 172 - .map_err(|e| { 173 - tracing::error!(error = %e, "failed to pre-store pending_did"); 174 - ApiError::new(ErrorCode::InternalError, "failed to store pending DID") 175 - })?; 176 - if result.rows_affected() == 0 { 177 - tracing::error!(account_id = %session.account_id, "pending account row vanished during DID pre-store"); 178 - return Err(ApiError::new( 179 - ErrorCode::InternalError, 180 - "account no longer exists", 181 - )); 182 - } 183 - false 184 - }; 231 + return Ok(true); 232 + } 185 233 186 - // Step 8: Check if the account is already fully promoted (idempotency guard). 234 + let result = sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 235 + .bind(did) 236 + .bind(account_id) 237 + .execute(db) 238 + .await 239 + .map_err(|e| { 240 + tracing::error!(error = %e, "failed to pre-store pending_did"); 241 + ApiError::new(ErrorCode::InternalError, "failed to store pending DID") 242 + })?; 243 + 244 + if result.rows_affected() == 0 { 245 + tracing::error!(account_id = %account_id, "pending account row vanished during DID pre-store"); 246 + return Err(ApiError::new( 247 + ErrorCode::InternalError, 248 + "account no longer exists", 249 + )); 250 + } 251 + Ok(false) 252 + } 253 + 254 + /// Check if the DID is already fully promoted (Step 8). 255 + async fn check_already_promoted(db: &sqlx::SqlitePool, did: &str) -> Result<(), ApiError> { 187 256 let already_promoted: bool = 188 257 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM accounts WHERE did = ?)") 189 258 .bind(did) 190 - .fetch_one(&state.db) 259 + .fetch_one(db) 191 260 .await 192 261 .map_err(|e| { 193 262 tracing::error!(error = %e, "failed to check accounts existence"); ··· 200 269 "DID is already fully promoted", 201 270 )); 202 271 } 272 + Ok(()) 273 + } 203 274 204 - // Step 9: POST the signed genesis operation to plc.directory (skipped on retry). 205 - if !skip_plc_directory { 206 - let plc_url = format!("{}/{}", state.config.plc_directory_url, did); 207 - let response = state 208 - .http_client 209 - .post(&plc_url) 210 - .body(signed_op_str.clone()) 211 - .header("Content-Type", "application/json") 212 - .send() 213 - .await 214 - .map_err(|e| { 215 - tracing::error!(error = %e, plc_url = %plc_url, "failed to contact plc.directory"); 216 - ApiError::new( 217 - ErrorCode::PlcDirectoryError, 218 - "failed to contact plc.directory", 219 - ) 220 - })?; 221 - 222 - if !response.status().is_success() { 223 - let status = response.status(); 224 - let body_text = response 225 - .text() 226 - .await 227 - .unwrap_or_else(|_| "<failed to read body>".to_string()); 228 - tracing::error!( 229 - status = %status, 230 - body = %body_text, 231 - "plc.directory rejected genesis operation" 232 - ); 233 - return Err(ApiError::new( 275 + /// POST the signed genesis operation to plc.directory (Step 9). 276 + async fn post_to_plc_directory( 277 + http_client: &reqwest::Client, 278 + plc_directory_url: &str, 279 + did: &str, 280 + signed_op_str: &str, 281 + ) -> Result<(), ApiError> { 282 + let plc_url = format!("{plc_directory_url}/{did}"); 283 + let response = http_client 284 + .post(&plc_url) 285 + .body(signed_op_str.to_string()) 286 + .header("Content-Type", "application/json") 287 + .send() 288 + .await 289 + .map_err(|e| { 290 + tracing::error!(error = %e, plc_url = %plc_url, "failed to contact plc.directory"); 291 + ApiError::new( 234 292 ErrorCode::PlcDirectoryError, 235 - format!("plc.directory returned {status}"), 236 - )); 237 - } 293 + "failed to contact plc.directory", 294 + ) 295 + })?; 296 + 297 + if !response.status().is_success() { 298 + let status = response.status(); 299 + let body_text = response 300 + .text() 301 + .await 302 + .unwrap_or_else(|_| "<failed to read body>".to_string()); 303 + tracing::error!( 304 + status = %status, 305 + body = %body_text, 306 + "plc.directory rejected genesis operation" 307 + ); 308 + return Err(ApiError::new( 309 + ErrorCode::PlcDirectoryError, 310 + format!("plc.directory returned {status}"), 311 + )); 238 312 } 313 + Ok(()) 314 + } 239 315 240 - // Step 10: Build the DID document from verified op fields. 241 - let did_document = build_did_document(&verified)?; 242 - let did_document_str = serde_json::to_string(&did_document).map_err(|e| { 316 + /// Atomically promote a pending account to a full account (Steps 10-12). 317 + /// 318 + /// In a single transaction: INSERT accounts + did_documents + sessions, 319 + /// then DELETE pending_sessions + devices + pending_accounts. 320 + async fn promote_account( 321 + db: &sqlx::SqlitePool, 322 + did: &str, 323 + email: &str, 324 + account_id: &str, 325 + did_document: &serde_json::Value, 326 + token_hash: &str, 327 + ) -> Result<(), ApiError> { 328 + let did_document_str = serde_json::to_string(did_document).map_err(|e| { 243 329 tracing::error!(error = %e, "failed to serialize DID document"); 244 330 ApiError::new(ErrorCode::InternalError, "failed to serialize DID document") 245 331 })?; 246 - 247 - // Step 11: Generate session token before entering the transaction so it can be 248 - // returned in the response regardless of which transaction path commits. 249 - let mut token_bytes = [0u8; 32]; 250 - rand_core::OsRng.fill_bytes(&mut token_bytes); 251 - let session_token = { 252 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 253 - URL_SAFE_NO_PAD.encode(token_bytes) 254 - }; 255 - let token_hash: String = { 256 - use sha2::{Digest, Sha256}; 257 - Sha256::digest(token_bytes) 258 - .iter() 259 - .map(|b| format!("{b:02x}")) 260 - .collect() 261 - }; 262 332 let session_id = uuid::Uuid::new_v4().to_string(); 263 333 264 - // Step 12: Atomically promote the account. 265 - let mut tx = state 266 - .db 334 + let mut tx = db 267 335 .begin() 268 336 .await 269 337 .inspect_err(|e| tracing::error!(error = %e, "failed to begin promotion transaction")) ··· 274 342 VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 275 343 ) 276 344 .bind(did) 277 - .bind(&email) 345 + .bind(email) 278 346 .execute(&mut *tx) 279 347 .await 280 348 .map_err(|e| { ··· 303 371 ) 304 372 .bind(&session_id) 305 373 .bind(did) 306 - .bind(&token_hash) 374 + .bind(token_hash) 307 375 .execute(&mut *tx) 308 376 .await 309 377 .inspect_err(|e| tracing::error!(error = %e, "failed to insert session")) 310 378 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create session"))?; 311 379 312 380 sqlx::query("DELETE FROM pending_sessions WHERE account_id = ?") 313 - .bind(&session.account_id) 381 + .bind(account_id) 314 382 .execute(&mut *tx) 315 383 .await 316 384 .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending sessions")) 317 385 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up sessions"))?; 318 386 319 387 sqlx::query("DELETE FROM devices WHERE account_id = ?") 320 - .bind(&session.account_id) 388 + .bind(account_id) 321 389 .execute(&mut *tx) 322 390 .await 323 391 .inspect_err(|e| tracing::error!(error = %e, "failed to delete devices")) 324 392 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up devices"))?; 325 393 326 394 sqlx::query("DELETE FROM pending_accounts WHERE id = ?") 327 - .bind(&session.account_id) 395 + .bind(account_id) 328 396 .execute(&mut *tx) 329 397 .await 330 398 .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending account")) ··· 335 403 .inspect_err(|e| tracing::error!(error = %e, "failed to commit promotion transaction")) 336 404 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to commit transaction"))?; 337 405 338 - // Step 13: Return the result. 339 - Ok(Json(CreateDidResponse { 340 - did: did.clone(), 341 - did_document, 342 - status: "active", 343 - session_token, 344 - })) 406 + Ok(()) 345 407 } 346 408 347 409 /// Construct a minimal DID Core document from a verified genesis operation. ··· 404 466 mod tests { 405 467 use super::*; 406 468 use crate::app::test_state_with_plc_url; 469 + use crate::routes::token::generate_token; 407 470 use axum::{ 408 471 body::Body, 409 472 http::{Request, StatusCode}, 410 473 }; 411 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 412 - use rand_core::{OsRng, RngCore}; 413 - use sha2::{Digest, Sha256}; 414 474 use tower::ServiceExt; // for `.oneshot()` 415 475 use uuid::Uuid; 416 476 use wiremock::{ ··· 490 550 .await 491 551 .expect("insert device"); 492 552 493 - let mut token_bytes = [0u8; 32]; 494 - OsRng.fill_bytes(&mut token_bytes); 495 - let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 496 - let token_hash: String = Sha256::digest(token_bytes) 497 - .iter() 498 - .map(|b| format!("{b:02x}")) 499 - .collect(); 553 + let token = generate_token(); 500 554 sqlx::query( 501 555 "INSERT INTO pending_sessions \ 502 556 (id, account_id, device_id, token_hash, created_at, expires_at) \ ··· 505 559 .bind(Uuid::new_v4().to_string()) 506 560 .bind(&account_id) 507 561 .bind(&device_id) 508 - .bind(&token_hash) 562 + .bind(&token.hash) 509 563 .execute(db) 510 564 .await 511 565 .expect("insert pending_session"); 512 566 513 567 TestSetup { 514 - session_token, 568 + session_token: token.plaintext, 515 569 account_id, 516 570 handle, 517 571 } ··· 655 709 656 710 // session row created with correct did and matching token_hash 657 711 let session_token_str = body["session_token"].as_str().unwrap(); 658 - let token_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD 659 - .decode(session_token_str) 660 - .expect("session_token should be valid base64url"); 661 - let expected_hash: String = { 662 - use sha2::{Digest, Sha256}; 663 - Sha256::digest(&token_bytes) 664 - .iter() 665 - .map(|b| format!("{b:02x}")) 666 - .collect() 667 - }; 712 + let expected_hash = crate::routes::token::hash_bearer_token(session_token_str).unwrap(); 668 713 let session_row: Option<(String,)> = 669 714 sqlx::query_as("SELECT did FROM sessions WHERE token_hash = ?") 670 715 .bind(&expected_hash) ··· 828 873 make_signed_op(&setup.handle, &state.config.public_url); 829 874 830 875 // Corrupt the sig: decode, flip one byte, re-encode. 876 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 831 877 let sig_str = signed_op["sig"].as_str().unwrap().to_string(); 832 878 let mut sig_bytes = URL_SAFE_NO_PAD.decode(&sig_str).unwrap(); 833 879 sig_bytes[0] ^= 0xff;
+9 -16
crates/relay/src/routes/create_handle.rs
··· 74 74 .execute(&state.db) 75 75 .await 76 76 .map_err(|e| { 77 - if let sqlx::Error::Database(db_err) = &e { 78 - if db_err.is_unique_violation() { 79 - return ApiError::new(ErrorCode::HandleTaken, "handle is already taken"); 80 - } 77 + if crate::db::is_unique_violation(&e) { 78 + return ApiError::new(ErrorCode::HandleTaken, "handle is already taken"); 81 79 } 82 80 tracing::error!(error = %e, "failed to insert handle"); 83 81 ApiError::new(ErrorCode::InternalError, "failed to register handle") ··· 165 163 mod tests { 166 164 use super::*; 167 165 use crate::app::test_state; 166 + use crate::routes::token::generate_token; 168 167 use axum::{ 169 168 body::Body, 170 169 http::{Request, StatusCode}, 171 170 }; 172 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 173 - use rand_core::{OsRng, RngCore}; 174 - use sha2::{Digest, Sha256}; 175 171 use std::future::Future; 176 172 use std::pin::Pin; 177 173 use std::sync::Arc; ··· 317 313 .await 318 314 .expect("insert account"); 319 315 320 - let mut token_bytes = [0u8; 32]; 321 - OsRng.fill_bytes(&mut token_bytes); 322 - let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 323 - let token_hash: String = Sha256::digest(token_bytes) 324 - .iter() 325 - .map(|b| format!("{b:02x}")) 326 - .collect(); 316 + let token = generate_token(); 327 317 328 318 sqlx::query( 329 319 "INSERT INTO sessions (id, did, device_id, token_hash, created_at, expires_at) \ ··· 331 321 ) 332 322 .bind(Uuid::new_v4().to_string()) 333 323 .bind(&did) 334 - .bind(&token_hash) 324 + .bind(&token.hash) 335 325 .execute(db) 336 326 .await 337 327 .expect("insert session"); 338 328 339 - TestSession { did, session_token } 329 + TestSession { 330 + did, 331 + session_token: token.plaintext, 332 + } 340 333 } 341 334 342 335 fn create_handle_request(session_token: &str, account_id: &str, handle: &str) -> Request<Body> {
+48 -114
crates/relay/src/routes/create_mobile_account.rs
··· 13 13 // ApiError on all failure paths 14 14 15 15 use axum::{extract::State, http::StatusCode, response::Json}; 16 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 17 - use rand_core::{OsRng, RngCore}; 18 16 use serde::{Deserialize, Serialize}; 19 - use sha2::{Digest, Sha256}; 20 17 use uuid::Uuid; 21 18 22 19 use common::{ApiError, ErrorCode}; 23 20 24 21 use crate::app::AppState; 25 22 use crate::routes::create_account::validate_handle; 26 - use crate::routes::register_device::{is_valid_platform, MAX_PUBLIC_KEY_LEN}; 23 + use crate::routes::register_device::{Platform, MAX_PUBLIC_KEY_LEN}; 24 + use crate::routes::token::generate_token; 27 25 28 26 #[derive(Deserialize)] 29 27 #[serde(rename_all = "camelCase")] ··· 31 29 email: String, 32 30 handle: String, 33 31 device_public_key: String, 34 - platform: String, 32 + platform: Platform, 35 33 claim_code: String, 36 34 } 37 35 ··· 49 47 State(state): State<AppState>, 50 48 Json(payload): Json<CreateMobileAccountRequest>, 51 49 ) -> Result<(StatusCode, Json<CreateMobileAccountResponse>), ApiError> { 52 - // --- Validate platform --- 53 - if !is_valid_platform(&payload.platform) { 54 - return Err(ApiError::new( 55 - ErrorCode::InvalidClaim, 56 - "platform must be one of: ios, android, macos, linux, windows", 57 - )); 58 - } 59 - 60 50 // --- Validate device_public_key --- 61 51 if payload.device_public_key.is_empty() { 62 52 return Err(ApiError::new( ··· 84 74 return Err(ApiError::new(ErrorCode::InvalidHandle, msg)); 85 75 } 86 76 87 - // --- Email uniqueness: check accounts and pending_accounts in one query --- 88 - // Fast-path rejection before the INSERT to avoid consuming a claim code slot on a 89 - // predictable conflict. The unique indexes remain the authoritative enforcement. 90 - let email_taken: i64 = sqlx::query_scalar( 91 - "SELECT CAST( 92 - (EXISTS(SELECT 1 FROM accounts WHERE email = ?) 93 - OR EXISTS(SELECT 1 FROM pending_accounts WHERE email = ?)) 94 - AS INTEGER)", 95 - ) 96 - .bind(&payload.email) 97 - .bind(&payload.email) 98 - .fetch_one(&state.db) 99 - .await 100 - .map_err(|e| { 101 - tracing::error!(error = %e, "failed to check email uniqueness"); 102 - ApiError::new(ErrorCode::InternalError, "failed to create account") 103 - })?; 104 - 105 - if email_taken != 0 { 77 + // --- Email uniqueness: fast-path rejection before INSERT --- 78 + if crate::routes::uniqueness::email_taken(&state.db, &payload.email) 79 + .await 80 + .map_err(|e| { 81 + tracing::error!(error = %e, "failed to check email uniqueness"); 82 + ApiError::new(ErrorCode::InternalError, "failed to create account") 83 + })? 84 + { 106 85 return Err(ApiError::new( 107 86 ErrorCode::AccountExists, 108 87 "an account with this email already exists", 109 88 )); 110 89 } 111 90 112 - // --- Handle uniqueness: check handles and pending_accounts in one query --- 113 - let handle_taken: i64 = sqlx::query_scalar( 114 - "SELECT CAST( 115 - (EXISTS(SELECT 1 FROM handles WHERE handle = ?) 116 - OR EXISTS(SELECT 1 FROM pending_accounts WHERE handle = ?)) 117 - AS INTEGER)", 118 - ) 119 - .bind(&payload.handle) 120 - .bind(&payload.handle) 121 - .fetch_one(&state.db) 122 - .await 123 - .map_err(|e| { 124 - tracing::error!(error = %e, "failed to check handle uniqueness"); 125 - ApiError::new(ErrorCode::InternalError, "failed to create account") 126 - })?; 127 - 128 - if handle_taken != 0 { 91 + // --- Handle uniqueness: fast-path rejection before INSERT --- 92 + if crate::routes::uniqueness::handle_taken(&state.db, &payload.handle) 93 + .await 94 + .map_err(|e| { 95 + tracing::error!(error = %e, "failed to check handle uniqueness"); 96 + ApiError::new(ErrorCode::InternalError, "failed to create account") 97 + })? 98 + { 129 99 return Err(ApiError::new( 130 100 ErrorCode::HandleTaken, 131 101 "this handle is already claimed", ··· 133 103 } 134 104 135 105 // --- Generate IDs and credentials --- 136 - // device_token / session_token: 32 random bytes → base64url (no padding) for the wire; 137 - // SHA-256 of the raw bytes → 64-char hex for the DB. 138 - // Plaintext tokens are returned once and never stored; future auth uses the hashes. 139 106 let account_id = Uuid::new_v4().to_string(); 140 107 let device_id = Uuid::new_v4().to_string(); 141 108 let session_id = Uuid::new_v4().to_string(); 142 109 143 - let mut device_token_bytes = [0u8; 32]; 144 - OsRng.fill_bytes(&mut device_token_bytes); 145 - let device_token = URL_SAFE_NO_PAD.encode(device_token_bytes); 146 - let device_token_hash: String = Sha256::digest(device_token_bytes) 147 - .iter() 148 - .map(|b| format!("{b:02x}")) 149 - .collect(); 150 - 151 - let mut session_token_bytes = [0u8; 32]; 152 - OsRng.fill_bytes(&mut session_token_bytes); 153 - let session_token = URL_SAFE_NO_PAD.encode(session_token_bytes); 154 - let session_token_hash: String = Sha256::digest(session_token_bytes) 155 - .iter() 156 - .map(|b| format!("{b:02x}")) 157 - .collect(); 110 + let device_token = generate_token(); 111 + let session_token = generate_token(); 158 112 159 113 // --- Atomically provision: redeem claim code + create account + register device + issue session --- 160 114 provision_mobile_account( ··· 165 119 email: &payload.email, 166 120 handle: &payload.handle, 167 121 device_id: &device_id, 168 - platform: &payload.platform, 122 + platform: payload.platform.as_str(), 169 123 public_key: &payload.device_public_key, 170 - device_token_hash: &device_token_hash, 124 + device_token_hash: &device_token.hash, 171 125 session_id: &session_id, 172 - session_token_hash: &session_token_hash, 126 + session_token_hash: &session_token.hash, 173 127 }, 174 128 ) 175 129 .await?; ··· 179 133 Json(CreateMobileAccountResponse { 180 134 account_id, 181 135 device_id, 182 - device_token, 183 - session_token, 136 + device_token: device_token.plaintext, 137 + session_token: session_token.plaintext, 184 138 next_step: "did_creation".to_string(), 185 139 }), 186 140 )) ··· 339 293 340 294 /// Classify a unique constraint violation from the pending_accounts INSERT into the 341 295 /// appropriate ApiError. Returns InternalError for non-unique-violation errors. 342 - /// 343 - /// Constraint name matching uses SQLite's stable "UNIQUE constraint failed: <table>.<column>" 344 - /// format. The fallthrough branch (unknown constraint) logs the constraint name so any 345 - /// unexpected violations surface in traces — matching the pattern in create_account.rs. 346 296 fn classify_pending_account_error(e: &sqlx::Error) -> ApiError { 347 - if let sqlx::Error::Database(db_err) = e { 348 - if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation { 349 - let msg = db_err.message(); 350 - if msg.contains("pending_accounts.email") { 351 - return ApiError::new( 352 - ErrorCode::AccountExists, 353 - "an account with this email already exists", 354 - ); 355 - } 356 - if msg.contains("pending_accounts.handle") { 357 - return ApiError::new(ErrorCode::HandleTaken, "this handle is already claimed"); 358 - } 359 - // Unknown unique constraint — log the name so it surfaces in traces. 297 + match crate::db::unique_violation_column(e, "pending_accounts") { 298 + Some("email") => { 299 + return ApiError::new( 300 + ErrorCode::AccountExists, 301 + "an account with this email already exists", 302 + ); 303 + } 304 + Some("handle") => { 305 + return ApiError::new(ErrorCode::HandleTaken, "this handle is already claimed"); 306 + } 307 + Some(col) => { 360 308 tracing::error!( 361 - constraint = msg, 362 - "unique violation on unexpected constraint in pending_accounts insert" 309 + column = col, 310 + "unique violation on unexpected column in pending_accounts insert" 363 311 ); 364 312 } 313 + None => {} 365 314 } 366 315 ApiError::new(ErrorCode::InternalError, "failed to create account") 367 316 } ··· 590 539 591 540 #[tokio::test] 592 541 async fn token_hashes_are_sha256_of_tokens() { 593 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 594 - use sha2::{Digest, Sha256}; 542 + use crate::routes::token::hash_bearer_token; 595 543 596 544 let state = test_state().await; 597 545 let db = state.db.clone(); ··· 611 559 let account_id = json["accountId"].as_str().unwrap(); 612 560 613 561 // device token hash 614 - let device_token_bytes = URL_SAFE_NO_PAD 615 - .decode(json["deviceToken"].as_str().unwrap()) 616 - .unwrap(); 617 - let expected_device_hash: String = Sha256::digest(&device_token_bytes) 618 - .iter() 619 - .map(|b| format!("{b:02x}")) 620 - .collect(); 562 + let expected_device_hash = 563 + hash_bearer_token(json["deviceToken"].as_str().unwrap()).unwrap(); 621 564 622 565 let (stored_device_hash,): (String,) = 623 566 sqlx::query_as("SELECT device_token_hash FROM devices WHERE id = ?") ··· 631 574 ); 632 575 633 576 // session token hash 634 - let session_token_bytes = URL_SAFE_NO_PAD 635 - .decode(json["sessionToken"].as_str().unwrap()) 636 - .unwrap(); 637 - let expected_session_hash: String = Sha256::digest(&session_token_bytes) 638 - .iter() 639 - .map(|b| format!("{b:02x}")) 640 - .collect(); 577 + let expected_session_hash = 578 + hash_bearer_token(json["sessionToken"].as_str().unwrap()).unwrap(); 641 579 642 580 let (stored_session_hash,): (String,) = 643 581 sqlx::query_as("SELECT token_hash FROM pending_sessions WHERE account_id = ?") ··· 945 883 // ── Platform validation ─────────────────────────────────────────────────── 946 884 947 885 #[tokio::test] 948 - async fn invalid_platform_returns_400() { 886 + async fn invalid_platform_returns_422() { 887 + // Invalid platform is caught by serde deserialization (422), not application logic (400). 949 888 let response = app(test_state().await) 950 889 .oneshot(post_create_mobile_account( 951 890 r#"{"email":"a@example.com","handle":"a.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"plan9","claimCode":"ABC123"}"#, ··· 953 892 .await 954 893 .unwrap(); 955 894 956 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 957 - let body = axum::body::to_bytes(response.into_body(), 4096) 958 - .await 959 - .unwrap(); 960 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 961 - assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 895 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 962 896 } 963 897 964 898 // ── Public key validation ─────────────────────────────────────────────────
+2
crates/relay/src/routes/mod.rs
··· 11 11 pub mod resolve_handle; 12 12 13 13 mod code_gen; 14 + pub(crate) mod token; 15 + pub(crate) mod uniqueness; 14 16 15 17 #[cfg(test)] 16 18 pub(crate) mod test_utils;
+46 -54
crates/relay/src/routes/register_device.rs
··· 10 10 // Returns: JSON { device_id, device_token, account_id } on success; ApiError on all failure paths 11 11 12 12 use axum::{extract::State, http::StatusCode, response::Json}; 13 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 14 - use rand_core::{OsRng, RngCore}; 15 13 use serde::{Deserialize, Serialize}; 16 - use sha2::{Digest, Sha256}; 17 14 use uuid::Uuid; 18 15 19 16 use common::{ApiError, ErrorCode}; 20 17 21 18 use crate::app::AppState; 19 + use crate::routes::token::generate_token; 22 20 23 21 /// Maximum allowed length for a device public key string. 24 22 /// A P-256 uncompressed public key in base64 is ~88 chars; 512 is generous ··· 31 29 pub struct RegisterDeviceRequest { 32 30 claim_code: String, 33 31 device_public_key: String, 34 - platform: String, 32 + platform: Platform, 35 33 } 36 34 37 35 #[derive(Serialize)] ··· 46 44 State(state): State<AppState>, 47 45 Json(payload): Json<RegisterDeviceRequest>, 48 46 ) -> Result<(StatusCode, Json<RegisterDeviceResponse>), ApiError> { 49 - // --- Validate platform --- 50 - if !is_valid_platform(&payload.platform) { 51 - return Err(ApiError::new( 52 - ErrorCode::InvalidClaim, 53 - "platform must be one of: ios, android, macos, linux, windows", 54 - )); 55 - } 56 - 57 47 // --- Validate device_public_key --- 58 48 if payload.device_public_key.is_empty() { 59 49 return Err(ApiError::new( ··· 69 59 } 70 60 71 61 // --- Generate device credentials --- 72 - // 32 random bytes → base64url (no padding) for the wire; SHA-256 hex for the DB. 73 - // The plaintext token is returned once and never stored; future auth uses the hash. 74 62 let device_id = Uuid::new_v4().to_string(); 75 - let mut token_bytes = [0u8; 32]; 76 - OsRng.fill_bytes(&mut token_bytes); 77 - let device_token = URL_SAFE_NO_PAD.encode(token_bytes); 78 - let device_token_hash: String = Sha256::digest(token_bytes) 79 - .iter() 80 - .map(|b| format!("{b:02x}")) 81 - .collect(); 63 + let device_token = generate_token(); 82 64 83 65 // --- Atomically redeem claim code and register device --- 84 66 let account_id = redeem_and_register( 85 67 &state.db, 86 68 &payload.claim_code, 87 69 &device_id, 88 - &payload.platform, 70 + payload.platform.as_str(), 89 71 &payload.device_public_key, 90 - &device_token_hash, 72 + &device_token.hash, 91 73 ) 92 74 .await?; 93 75 ··· 95 77 StatusCode::CREATED, 96 78 Json(RegisterDeviceResponse { 97 79 device_id, 98 - device_token, 80 + device_token: device_token.plaintext, 99 81 account_id, 100 82 }), 101 83 )) 102 84 } 103 85 104 - pub(crate) fn is_valid_platform(platform: &str) -> bool { 105 - matches!(platform, "ios" | "android" | "macos" | "linux" | "windows") 86 + /// Supported device platforms. 87 + /// 88 + /// Deserialized from lowercase strings (`"ios"`, `"android"`, etc.) by serde. 89 + /// Stored as the same lowercase string in the database via `as_str()`. 90 + #[derive(Debug, Clone, Deserialize)] 91 + #[serde(rename_all = "lowercase")] 92 + pub(crate) enum Platform { 93 + Ios, 94 + Android, 95 + Macos, 96 + Linux, 97 + Windows, 98 + } 99 + 100 + impl Platform { 101 + pub fn as_str(&self) -> &'static str { 102 + match self { 103 + Platform::Ios => "ios", 104 + Platform::Android => "android", 105 + Platform::Macos => "macos", 106 + Platform::Linux => "linux", 107 + Platform::Windows => "windows", 108 + } 109 + } 106 110 } 107 111 108 112 /// Atomically redeem a claim code and register the device in a single transaction. ··· 412 416 413 417 #[tokio::test] 414 418 async fn token_hash_is_sha256_of_token() { 415 - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 416 - use sha2::{Digest, Sha256}; 419 + use crate::routes::token::hash_bearer_token; 417 420 418 421 let state = test_state().await; 419 422 let db = state.db.clone(); ··· 434 437 let device_token = json["deviceToken"].as_str().unwrap(); 435 438 let device_id = json["deviceId"].as_str().unwrap(); 436 439 437 - let token_bytes = URL_SAFE_NO_PAD.decode(device_token).unwrap(); 438 - let expected_hash: String = Sha256::digest(&token_bytes) 439 - .iter() 440 - .map(|b| format!("{b:02x}")) 441 - .collect(); 440 + let expected_hash = hash_bearer_token(device_token).unwrap(); 442 441 443 442 let stored_hash: (String,) = 444 443 sqlx::query_as("SELECT device_token_hash FROM devices WHERE id = ?") ··· 644 643 } 645 644 646 645 #[tokio::test] 647 - async fn invalid_platform_returns_400() { 646 + async fn invalid_platform_returns_422() { 647 + // Invalid platform is caught by serde deserialization (422), not application logic. 648 648 let response = app(test_state().await) 649 649 .oneshot(post_register_device( 650 650 r#"{"claimCode":"ABC123","devicePublicKey":"dGVzdC1rZXk=","platform":"plan9"}"#, ··· 652 652 .await 653 653 .unwrap(); 654 654 655 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 656 - let body = axum::body::to_bytes(response.into_body(), 4096) 657 - .await 658 - .unwrap(); 659 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 660 - assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 655 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 661 656 } 662 657 663 658 #[tokio::test] 664 659 async fn platform_is_case_sensitive() { 660 + // serde's rename_all = "lowercase" is strict: "iOS" != "ios". 665 661 let response = app(test_state().await) 666 662 .oneshot(post_register_device( 667 663 r#"{"claimCode":"ABC123","devicePublicKey":"dGVzdC1rZXk=","platform":"iOS"}"#, ··· 669 665 .await 670 666 .unwrap(); 671 667 672 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 673 - let body = axum::body::to_bytes(response.into_body(), 4096) 674 - .await 675 - .unwrap(); 676 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 677 - assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 668 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 678 669 } 679 670 680 671 // ── Public key validation ───────────────────────────────────────────────── ··· 775 766 assert_eq!(json["error"]["code"], "INTERNAL_ERROR"); 776 767 } 777 768 778 - // ── Pure unit tests ─────────────────────────────────────────────────────── 769 + // ── Platform enum unit tests ──────────────────────────────────────────── 779 770 780 771 #[test] 781 - fn is_valid_platform_accepts_known_platforms() { 772 + fn platform_deserializes_known_values() { 782 773 for p in ["ios", "android", "macos", "linux", "windows"] { 783 - assert!(super::is_valid_platform(p), "{p} must be valid"); 774 + let result: Result<super::Platform, _> = serde_json::from_str(&format!("\"{p}\"")); 775 + assert!(result.is_ok(), "{p} must deserialize"); 784 776 } 785 777 } 786 778 787 779 #[test] 788 - fn is_valid_platform_rejects_unknown() { 789 - assert!(!super::is_valid_platform("plan9")); 790 - assert!(!super::is_valid_platform("")); 791 - assert!(!super::is_valid_platform("iOS")); // case-sensitive 792 - assert!(!super::is_valid_platform("Windows")); // case-sensitive 780 + fn platform_rejects_unknown_values() { 781 + for p in ["plan9", "", "iOS", "Windows"] { 782 + let result: Result<super::Platform, _> = serde_json::from_str(&format!("\"{p}\"")); 783 + assert!(result.is_err(), "{p} must be rejected"); 784 + } 793 785 } 794 786 }
+112
crates/relay/src/routes/token.rs
··· 1 + // Token generation and hashing utilities. 2 + // 3 + // All session tokens and device tokens follow the same format: 4 + // - 32 cryptographically random bytes 5 + // - Plaintext: base64url-no-pad encoding (43 chars, returned to the client once) 6 + // - Storage: SHA-256 hex digest (64 chars, stored in the database) 7 + // 8 + // This module is the single source of truth for that format. Auth verification 9 + // (decode + hash) lives here too so the encoding stays consistent. 10 + 11 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 12 + use rand_core::{OsRng, RngCore}; 13 + use sha2::{Digest, Sha256}; 14 + 15 + use common::{ApiError, ErrorCode}; 16 + 17 + /// A freshly generated token: plaintext for the wire, hash for the database. 18 + pub struct GeneratedToken { 19 + /// Base64url-no-pad encoded token (43 chars). Returned to the client once. 20 + pub plaintext: String, 21 + /// SHA-256 hex digest of the raw bytes (64 chars). Stored in the database. 22 + pub hash: String, 23 + } 24 + 25 + /// Generate a new 32-byte random token. 26 + /// 27 + /// Returns the base64url plaintext (for the client) and the SHA-256 hex hash 28 + /// (for database storage). The raw bytes are not retained. 29 + pub fn generate_token() -> GeneratedToken { 30 + let mut bytes = [0u8; 32]; 31 + OsRng.fill_bytes(&mut bytes); 32 + GeneratedToken { 33 + plaintext: URL_SAFE_NO_PAD.encode(bytes), 34 + hash: sha256_hex(&bytes), 35 + } 36 + } 37 + 38 + /// SHA-256 hash of `data`, returned as a lowercase hex string (64 chars). 39 + pub fn sha256_hex(data: &[u8]) -> String { 40 + Sha256::digest(data) 41 + .iter() 42 + .map(|b| format!("{b:02x}")) 43 + .collect() 44 + } 45 + 46 + /// Decode a base64url-no-pad token and return its SHA-256 hex hash. 47 + /// 48 + /// Used by auth functions to convert a Bearer token from the wire into the 49 + /// hash format stored in the database. 50 + pub fn hash_bearer_token(base64url_token: &str) -> Result<String, ApiError> { 51 + let bytes = URL_SAFE_NO_PAD 52 + .decode(base64url_token) 53 + .map_err(|_| ApiError::new(ErrorCode::Unauthorized, "invalid session token"))?; 54 + Ok(sha256_hex(&bytes)) 55 + } 56 + 57 + #[cfg(test)] 58 + mod tests { 59 + use super::*; 60 + 61 + #[test] 62 + fn generate_token_produces_43_char_base64url() { 63 + let token = generate_token(); 64 + assert_eq!(token.plaintext.len(), 43); 65 + assert!(token 66 + .plaintext 67 + .chars() 68 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); 69 + } 70 + 71 + #[test] 72 + fn generate_token_produces_64_char_hex_hash() { 73 + let token = generate_token(); 74 + assert_eq!(token.hash.len(), 64); 75 + assert!(token.hash.chars().all(|c| c.is_ascii_hexdigit())); 76 + } 77 + 78 + #[test] 79 + fn hash_matches_manual_computation() { 80 + let token = generate_token(); 81 + let decoded = URL_SAFE_NO_PAD.decode(&token.plaintext).unwrap(); 82 + let expected = sha256_hex(&decoded); 83 + assert_eq!(token.hash, expected); 84 + } 85 + 86 + #[test] 87 + fn hash_bearer_token_round_trips_with_generate() { 88 + let token = generate_token(); 89 + let hash = hash_bearer_token(&token.plaintext).unwrap(); 90 + assert_eq!(hash, token.hash); 91 + } 92 + 93 + #[test] 94 + fn hash_bearer_token_rejects_invalid_base64() { 95 + let result = hash_bearer_token("not-valid-base64url!!!"); 96 + assert!(result.is_err()); 97 + } 98 + 99 + #[test] 100 + fn sha256_hex_is_deterministic() { 101 + let data = b"test data"; 102 + assert_eq!(sha256_hex(data), sha256_hex(data)); 103 + } 104 + 105 + #[test] 106 + fn different_tokens_produce_different_hashes() { 107 + let t1 = generate_token(); 108 + let t2 = generate_token(); 109 + assert_ne!(t1.hash, t2.hash); 110 + assert_ne!(t1.plaintext, t2.plaintext); 111 + } 112 + }
+38
crates/relay/src/routes/uniqueness.rs
··· 1 + // Pre-flight uniqueness checks for email and handle. 2 + // 3 + // These queries check both fully-provisioned tables (accounts, handles) and the 4 + // pending_accounts table. They are fast-path rejections — the unique indexes on 5 + // the underlying tables remain the authoritative enforcement. These checks prevent 6 + // consuming a claim code slot on a predictable conflict. 7 + 8 + use sqlx::SqlitePool; 9 + 10 + /// Returns `true` if the email already exists in `accounts` or `pending_accounts`. 11 + pub async fn email_taken(db: &SqlitePool, email: &str) -> Result<bool, sqlx::Error> { 12 + let taken: i64 = sqlx::query_scalar( 13 + "SELECT CAST( 14 + (EXISTS(SELECT 1 FROM accounts WHERE email = ?) 15 + OR EXISTS(SELECT 1 FROM pending_accounts WHERE email = ?)) 16 + AS INTEGER)", 17 + ) 18 + .bind(email) 19 + .bind(email) 20 + .fetch_one(db) 21 + .await?; 22 + Ok(taken != 0) 23 + } 24 + 25 + /// Returns `true` if the handle already exists in `handles` or `pending_accounts`. 26 + pub async fn handle_taken(db: &SqlitePool, handle: &str) -> Result<bool, sqlx::Error> { 27 + let taken: i64 = sqlx::query_scalar( 28 + "SELECT CAST( 29 + (EXISTS(SELECT 1 FROM handles WHERE handle = ?) 30 + OR EXISTS(SELECT 1 FROM pending_accounts WHERE handle = ?)) 31 + AS INTEGER)", 32 + ) 33 + .bind(handle) 34 + .bind(handle) 35 + .fetch_one(db) 36 + .await?; 37 + Ok(taken != 0) 38 + }
+128
docs/design-plans/2026-03-16-relay-refactoring.md
··· 1 + # Relay Crate Refactoring Plan 2 + 3 + **Date:** 2026-03-16 4 + **Status:** Complete 5 + 6 + ## Context 7 + 8 + Deep codebase scan revealed several patterns of duplication in the relay crate that increase maintenance burden and bug risk. The crypto, common, and workspace-level code are in good shape. This plan addresses relay-specific technical debt in priority order. 9 + 10 + ## P1: Extract Token Generation & Hashing Module 11 + 12 + **Problem:** The pattern — generate 32 random bytes, base64url-encode, SHA-256 hash to hex — is copy-pasted 10+ times across route handlers and tests. 13 + 14 + **Locations:** 15 + - `create_mobile_account.rs:143-149` (device token) 16 + - `create_mobile_account.rs:151-157` (session token) 17 + - `register_device.rs:76-79` (device token) 18 + - `create_did.rs:250-258` (session token) 19 + - `create_handle.rs:321-323` (session token) 20 + - `auth.rs:109-115` (decode+hash in `require_pending_session`) 21 + - `auth.rs:177-184` (decode+hash in `require_session`) 22 + - Plus ~8 more in test modules 23 + 24 + **Solution:** New module `crates/relay/src/routes/token.rs` with: 25 + - `generate_token() -> (String, String)` — returns (plaintext, hash) 26 + - `sha256_hex(data: &[u8]) -> String` — reusable hex hashing 27 + - `hash_token(base64url_token: &str) -> Result<String, ApiError>` — decode + hash for auth path 28 + 29 + **Impact:** ~60 lines removed in production, ~40 in tests. Single source of truth for token format. 30 + 31 + ## P2: Centralize Unique Constraint Classification 32 + 33 + **Problem:** Four divergent implementations parse SQLite's `"UNIQUE constraint failed: <table>.<column>"` message. 34 + 35 + **Locations:** 36 + - `create_account.rs:267` — `unique_violation_source()` returns `Option<UniqueConflict>` 37 + - `create_mobile_account.rs:346` — `classify_pending_account_error()` parses table.column 38 + - `create_did.rs:53` — `is_unique_violation()` boolean check 39 + - `claim_codes.rs:121` — `is_unique_violation()` duplicate of above 40 + 41 + **Solution:** New module `crates/relay/src/db/constraint.rs` with: 42 + - `is_unique_violation(e: &sqlx::Error) -> bool` 43 + - `unique_violation_column(e: &sqlx::Error, table: &str) -> Option<String>` 44 + 45 + **Impact:** Single place to update if sqlx changes error format. Eliminates 4 independent parsers. 46 + 47 + ## P3: Extract Email/Handle Uniqueness Query Helpers 48 + 49 + **Problem:** Identical OR EXISTS pre-flight queries duplicated across two handlers. 50 + 51 + **Locations:** 52 + - `create_account.rs:70-90` (email), `94-114` (handle) 53 + - `create_mobile_account.rs:90-110` (email), `113-133` (handle) 54 + 55 + **Solution:** Query helpers in a shared location (e.g., `crates/relay/src/db/queries.rs` or a new `routes/uniqueness.rs`): 56 + - `email_taken(db: &SqlitePool, email: &str) -> Result<bool, sqlx::Error>` 57 + - `handle_taken(db: &SqlitePool, handle: &str) -> Result<bool, sqlx::Error>` 58 + 59 + **Impact:** Single source of truth for cross-table uniqueness logic. Adding a third table to check requires one change, not two. 60 + 61 + ## P4: Platform Enum 62 + 63 + **Problem:** Platform validation is string-based via `is_valid_platform()` match. Invalid platforms only caught at runtime. 64 + 65 + **Locations:** 66 + - `register_device.rs:104-106` (definition) 67 + - `create_mobile_account.rs:26,53` (import + call) 68 + 69 + **Solution:** Replace with serde-deserializable enum: 70 + ```rust 71 + #[derive(Debug, Clone, Deserialize, Serialize)] 72 + #[serde(rename_all = "lowercase")] 73 + pub enum Platform { 74 + Ios, Android, Macos, Linux, Windows, 75 + } 76 + ``` 77 + 78 + Deserialization rejects invalid platforms before handler code runs. 79 + 80 + **Impact:** Moves validation to the type system. Eliminates `is_valid_platform()` function entirely. 81 + 82 + ## P5: base32 Helper in Crypto 83 + 84 + **Problem:** Identical 5-line base32 encoding setup duplicated in `plc.rs`. 85 + 86 + **Locations:** 87 + - `crates/crypto/src/plc.rs:219-224` (build) 88 + - `crates/crypto/src/plc.rs:333-338` (verify) 89 + 90 + **Solution:** Extract `fn base32_lowercase() -> Result<Encoding, CryptoError>`. 91 + 92 + **Impact:** Trivial, but eliminates the only DRY violation in the crypto crate. 93 + 94 + ## P6: Break Up `create_did` Handler 95 + 96 + **Problem:** Single 270-line function with 13 numbered steps handling auth, validation, external HTTP, and multi-table transaction. 97 + 98 + **Location:** `crates/relay/src/routes/create_did.rs:76-345` 99 + 100 + **Solution:** Extract phases into named functions: 101 + - `load_pending_account()` — DB lookup 102 + - `validate_genesis_op()` — payload verification 103 + - `post_to_plc_if_needed()` — external HTTP call 104 + - `promote_account()` — transaction: move pending → accounts, issue session 105 + 106 + **Impact:** Each phase becomes independently testable. Handler reads like a recipe. 107 + 108 + ## P7: Clean AC References in Crypto Tests 109 + 110 + **Problem:** Per project convention, no ticket/AC references in source code. `crates/crypto/src/shamir.rs` still has AC-prefixed section comments. 111 + 112 + **Location:** `crates/crypto/src/shamir.rs:171,191,255` 113 + 114 + **Solution:** Reword to describe behavior, not acceptance criteria. 115 + 116 + **Impact:** Style-only. 117 + 118 + ## Implementation Order 119 + 120 + | Step | Priority | Effort | Status | 121 + |------|----------|--------|--------| 122 + | 1. Token module | P1 | ~2hr | Done | 123 + | 2. Constraint classification | P2 | ~1hr | Done | 124 + | 3. Uniqueness query helpers | P3 | ~1hr | Done | 125 + | 4. Platform enum | P4 | ~1hr | Done | 126 + | 5. base32 helper | P5 | ~15min | Done | 127 + | 6. Break up create_did | P6 | ~2hr | Done | 128 + | 7. Clean AC references | P7 | ~15min | Done |

History

1 round 0 comments
sign up or login to add to the discussion
malpercio.dev submitted #0
1 commit
expand
refactor: extract shared token, constraint, and uniqueness helpers across relay
1/1 success
expand
expand 0 comments
pull request successfully merged