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

Configure Feed

Select the types of activity you want to include in your feed.

feat(relay): implement POST /v1/dids with pre-store retry resilience (MM-89)

authored by

Malpercio and committed by
Tangled
10b6e096 20fd1d5a

+839
+22
bruno/create-did.bru
··· 1 + meta { 2 + name: Create DID 3 + type: http 4 + seq: 8 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/v1/dids 9 + body: json 10 + auth: bearer 11 + } 12 + 13 + auth:bearer { 14 + token: {{pendingSessionToken}} 15 + } 16 + 17 + body:json { 18 + { 19 + "signingKey": "{{signingKeyId}}", 20 + "rotationKey": "{{rotationKeyId}}" 21 + } 22 + }
+2
crates/relay/src/app.rs
··· 14 14 15 15 use crate::routes::claim_codes::claim_codes; 16 16 use crate::routes::create_account::create_account; 17 + use crate::routes::create_did::create_did_handler; 17 18 use crate::routes::create_mobile_account::create_mobile_account; 18 19 use crate::routes::create_signing_key::create_signing_key; 19 20 use crate::routes::describe_server::describe_server; ··· 97 98 .route("/v1/accounts/claim-codes", post(claim_codes)) 98 99 .route("/v1/accounts/mobile", post(create_mobile_account)) 99 100 .route("/v1/devices", post(register_device)) 101 + .route("/v1/dids", post(create_did_handler)) 100 102 .route("/v1/relay/keys", post(create_signing_key)) 101 103 .layer(CorsLayer::permissive()) 102 104 .layer(TraceLayer::new_for_http().make_span_with(OtelMakeSpan))
+814
crates/relay/src/routes/create_did.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // POST /v1/dids — DID creation and account promotion 4 + // 5 + // Inputs: 6 + // - Authorization: Bearer <pending_session_token> 7 + // - JSON body: { "signingKey": "did:key:z...", "rotationKey": "did:key:z..." } 8 + // 9 + // Processing steps: 10 + // 1. require_pending_session → PendingSessionInfo { account_id, device_id } 11 + // 2. SELECT handle, pending_did FROM pending_accounts WHERE id = account_id 12 + // 3. SELECT private_key_encrypted FROM relay_signing_keys WHERE id = signing_key 13 + // 4. decrypt_private_key(encrypted, master_key) 14 + // 5. build_did_plc_genesis_op(rotation_key, signing_key, private_key, handle, public_url) 15 + // 6. If pending_did IS NULL: UPDATE pending_accounts SET pending_did = did (pre-store resilience) 16 + // 7. If pending_did IS NOT NULL (retry): skip step 8 17 + // 8. POST {plc_directory_url}/{did} with signed_op_json 18 + // 9. Atomic transaction: 19 + // INSERT accounts (did, email, password_hash=NULL) 20 + // INSERT did_documents (did, document) 21 + // INSERT handles (handle, did) 22 + // DELETE pending_sessions WHERE account_id = ? 23 + // DELETE pending_accounts WHERE id = ? 24 + // 10. Return { "did": "did:plc:...", "status": "active" } 25 + // 26 + // Outputs (success): 200 { "did": "did:plc:...", "status": "active" } 27 + // Outputs (error): 401 UNAUTHORIZED, 404 NOT_FOUND, 409 DID_ALREADY_EXISTS, 28 + // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 29 + 30 + use axum::{extract::State, http::HeaderMap, Json}; 31 + use serde::{Deserialize, Serialize}; 32 + 33 + use crate::app::AppState; 34 + use crate::routes::auth::require_pending_session; 35 + use common::{ApiError, ErrorCode}; 36 + 37 + #[derive(Deserialize)] 38 + #[serde(rename_all = "camelCase")] 39 + pub struct CreateDidRequest { 40 + pub signing_key: String, 41 + pub rotation_key: String, 42 + } 43 + 44 + #[derive(Serialize)] 45 + pub struct CreateDidResponse { 46 + pub did: String, 47 + pub status: &'static str, 48 + } 49 + 50 + pub async fn create_did_handler( 51 + State(state): State<AppState>, 52 + headers: HeaderMap, 53 + Json(payload): Json<CreateDidRequest>, 54 + ) -> Result<Json<CreateDidResponse>, ApiError> { 55 + // Step 1: Authenticate via pending_session Bearer token. 56 + let session = require_pending_session(&headers, &state.db).await?; 57 + 58 + // Step 2: Load pending account details. 59 + let (handle, pending_did, email): (String, Option<String>, String) = sqlx::query_as( 60 + "SELECT handle, pending_did, email FROM pending_accounts WHERE id = ?", 61 + ) 62 + .bind(&session.account_id) 63 + .fetch_optional(&state.db) 64 + .await 65 + .map_err(|e| { 66 + tracing::error!(error = %e, "failed to query pending account"); 67 + ApiError::new(ErrorCode::InternalError, "failed to load account") 68 + })? 69 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 70 + 71 + // Step 3: Look up signing key in relay_signing_keys. 72 + let (private_key_encrypted,): (String,) = sqlx::query_as( 73 + "SELECT private_key_encrypted FROM relay_signing_keys WHERE id = ?", 74 + ) 75 + .bind(&payload.signing_key) 76 + .fetch_optional(&state.db) 77 + .await 78 + .map_err(|e| { 79 + tracing::error!(error = %e, "failed to query relay signing key"); 80 + ApiError::new(ErrorCode::InternalError, "key lookup failed") 81 + })? 82 + .ok_or_else(|| { 83 + ApiError::new(ErrorCode::NotFound, "signing key not found in relay_signing_keys") 84 + })?; 85 + 86 + // Step 4: Decrypt the private key using the master key from config. 87 + let master_key: &[u8; 32] = state 88 + .config 89 + .signing_key_master_key 90 + .as_ref() 91 + .map(|s| &*s.0) 92 + .ok_or_else(|| { 93 + ApiError::new(ErrorCode::InternalError, "signing key master key not configured") 94 + })?; 95 + 96 + let private_key_bytes = crypto::decrypt_private_key(&private_key_encrypted, master_key) 97 + .map_err(|e| { 98 + tracing::error!(error = %e, "failed to decrypt signing key"); 99 + ApiError::new(ErrorCode::InternalError, "failed to decrypt signing key") 100 + })?; 101 + 102 + // Step 5: Build the genesis operation and derive the DID. 103 + let rotation_key = crypto::DidKeyUri(payload.rotation_key.clone()); 104 + let signing_key_uri = crypto::DidKeyUri(payload.signing_key.clone()); 105 + 106 + let genesis = crypto::build_did_plc_genesis_op( 107 + &rotation_key, 108 + &signing_key_uri, 109 + &private_key_bytes, 110 + &handle, 111 + &state.config.public_url, 112 + ) 113 + .map_err(|e| { 114 + tracing::error!(error = %e, "failed to build genesis op"); 115 + ApiError::new(ErrorCode::InternalError, "failed to build genesis operation") 116 + })?; 117 + 118 + let did = genesis.did.clone(); 119 + let signed_op_json = genesis.signed_op_json; 120 + 121 + // Step 6: Pre-store the DID for retry resilience. 122 + // If pending_did is already set, we are on a retry path — skip the plc.directory call. 123 + let skip_plc_directory = if let Some(pre_stored_did) = &pending_did { 124 + // Retry: use the pre-stored DID (should match — same deterministic inputs). 125 + tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, skipping plc.directory"); 126 + true 127 + } else { 128 + // First attempt: write the DID before calling plc.directory. 129 + sqlx::query( 130 + "UPDATE pending_accounts SET pending_did = ? WHERE id = ?", 131 + ) 132 + .bind(&did) 133 + .bind(&session.account_id) 134 + .execute(&state.db) 135 + .await 136 + .map_err(|e| { 137 + tracing::error!(error = %e, "failed to pre-store pending_did"); 138 + ApiError::new(ErrorCode::InternalError, "failed to store pending DID") 139 + })?; 140 + false 141 + }; 142 + 143 + // Step 7: Check if the account is already fully promoted (idempotency guard for AC2.10). 144 + let already_promoted: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM accounts WHERE did = ?)") 145 + .bind(&did) 146 + .fetch_one(&state.db) 147 + .await 148 + .map_err(|e| { 149 + tracing::error!(error = %e, "failed to check accounts existence"); 150 + ApiError::new(ErrorCode::InternalError, "database error") 151 + })?; 152 + 153 + if already_promoted { 154 + return Err(ApiError::new(ErrorCode::DidAlreadyExists, "DID is already fully promoted")); 155 + } 156 + 157 + // Step 8: POST the genesis operation to plc.directory (skipped on retry). 158 + if !skip_plc_directory { 159 + let plc_url = format!("{}/{}", state.config.plc_directory_url, did); 160 + let response = state 161 + .http_client 162 + .post(&plc_url) 163 + .body(signed_op_json.clone()) 164 + .header("Content-Type", "application/json") 165 + .send() 166 + .await 167 + .map_err(|e| { 168 + tracing::error!(error = %e, plc_url = %plc_url, "failed to contact plc.directory"); 169 + ApiError::new(ErrorCode::PlcDirectoryError, "failed to contact plc.directory") 170 + })?; 171 + 172 + if !response.status().is_success() { 173 + let status = response.status(); 174 + tracing::error!(status = %status, "plc.directory rejected genesis operation"); 175 + return Err(ApiError::new( 176 + ErrorCode::PlcDirectoryError, 177 + format!("plc.directory returned {status}"), 178 + )); 179 + } 180 + } 181 + 182 + // Step 9: Build the DID document for local storage. 183 + let did_document = build_did_document(&did, &handle, &payload.signing_key, &state.config.public_url); 184 + 185 + // Step 10: Atomically promote the account. 186 + let mut tx = state 187 + .db 188 + .begin() 189 + .await 190 + .inspect_err(|e| tracing::error!(error = %e, "failed to begin promotion transaction")) 191 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to begin transaction"))?; 192 + 193 + sqlx::query( 194 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 195 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 196 + ) 197 + .bind(&did) 198 + .bind(&email) 199 + .execute(&mut *tx) 200 + .await 201 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert account")) 202 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?; 203 + 204 + sqlx::query( 205 + "INSERT INTO did_documents (did, document, created_at, updated_at) \ 206 + VALUES (?, ?, datetime('now'), datetime('now'))", 207 + ) 208 + .bind(&did) 209 + .bind(&did_document) 210 + .execute(&mut *tx) 211 + .await 212 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert did_document")) 213 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to store DID document"))?; 214 + 215 + sqlx::query( 216 + "INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))", 217 + ) 218 + .bind(&handle) 219 + .bind(&did) 220 + .execute(&mut *tx) 221 + .await 222 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert handle")) 223 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register handle"))?; 224 + 225 + sqlx::query("DELETE FROM pending_sessions WHERE account_id = ?") 226 + .bind(&session.account_id) 227 + .execute(&mut *tx) 228 + .await 229 + .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending sessions")) 230 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up sessions"))?; 231 + 232 + sqlx::query("DELETE FROM devices WHERE account_id = ?") 233 + .bind(&session.account_id) 234 + .execute(&mut *tx) 235 + .await 236 + .inspect_err(|e| tracing::error!(error = %e, "failed to delete devices")) 237 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up devices"))?; 238 + 239 + sqlx::query("DELETE FROM pending_accounts WHERE id = ?") 240 + .bind(&session.account_id) 241 + .execute(&mut *tx) 242 + .await 243 + .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending account")) 244 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up account"))?; 245 + 246 + tx.commit() 247 + .await 248 + .inspect_err(|e| tracing::error!(error = %e, "failed to commit promotion transaction")) 249 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to commit transaction"))?; 250 + 251 + Ok(Json(CreateDidResponse { did, status: "active" })) 252 + } 253 + 254 + /// Construct a minimal DID Core document from known fields. 255 + /// 256 + /// No I/O — pure construction from parameters. 257 + fn build_did_document( 258 + did: &str, 259 + handle: &str, 260 + signing_key_did: &str, 261 + service_endpoint: &str, 262 + ) -> String { 263 + // Extract the multibase-encoded public key from the did:key URI. 264 + // did:key:zAbcDef... → publicKeyMultibase = "zAbcDef..." 265 + let public_key_multibase = signing_key_did 266 + .strip_prefix("did:key:") 267 + .unwrap_or(signing_key_did); 268 + 269 + serde_json::json!({ 270 + "@context": [ 271 + "https://www.w3.org/ns/did/v1" 272 + ], 273 + "id": did, 274 + "alsoKnownAs": [format!("at://{handle}")], 275 + "verificationMethod": [{ 276 + "id": format!("{did}#atproto"), 277 + "type": "Multikey", 278 + "controller": did, 279 + "publicKeyMultibase": public_key_multibase 280 + }], 281 + "service": [{ 282 + "id": "#atproto_pds", 283 + "type": "AtprotoPersonalDataServer", 284 + "serviceEndpoint": service_endpoint 285 + }] 286 + }) 287 + .to_string() 288 + } 289 + 290 + // ── Tests ──────────────────────────────────────────────────────────────────── 291 + 292 + #[cfg(test)] 293 + mod tests { 294 + use super::*; 295 + use crate::app::test_state_with_plc_url; 296 + use axum::{ 297 + body::Body, 298 + http::{Request, StatusCode}, 299 + }; 300 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 301 + use rand_core::{OsRng, RngCore}; 302 + use sha2::{Digest, Sha256}; 303 + use tower::ServiceExt; // for `.oneshot()` 304 + use uuid::Uuid; 305 + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path_regex}}; 306 + 307 + // ── Test setup helpers ──────────────────────────────────────────────────── 308 + 309 + /// A test master key: 32 bytes of 0x01. 310 + const TEST_MASTER_KEY: [u8; 32] = [0x01u8; 32]; 311 + 312 + /// All data needed to call POST /v1/dids in a test. 313 + struct TestSetup { 314 + session_token: String, 315 + signing_key_id: String, 316 + rotation_key_id: String, 317 + account_id: String, 318 + /// The handle stored in `pending_accounts`. Needed for AC2.10 to re-create 319 + /// a second pending account that derives the same DID (same keys + same handle). 320 + handle: String, 321 + } 322 + 323 + /// Insert all prerequisite rows for a DID-creation test. 324 + /// 325 + /// Inserts: relay_signing_key, pending_account (with claim code), device, pending_session. 326 + /// 327 + /// Pre-step: Read `crates/relay/src/routes/test_utils.rs` to see if helpers already 328 + /// exist for inserting claim codes, pending accounts, or pending sessions. Use them here 329 + /// if available. If not, use the raw SQL below. 330 + async fn insert_test_data(db: &sqlx::SqlitePool) -> TestSetup { 331 + use crypto::{encrypt_private_key, generate_p256_keypair}; 332 + 333 + // Generate signing and rotation keypairs. 334 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 335 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 336 + 337 + // Encrypt the signing private key with the test master key. 338 + let encrypted = 339 + encrypt_private_key(&signing_kp.private_key_bytes, &TEST_MASTER_KEY) 340 + .expect("encrypt key"); 341 + 342 + // Insert relay_signing_key. 343 + sqlx::query( 344 + "INSERT INTO relay_signing_keys \ 345 + (id, algorithm, public_key, private_key_encrypted, created_at) \ 346 + VALUES (?, 'p256', ?, ?, datetime('now'))", 347 + ) 348 + .bind(&signing_kp.key_id.0) 349 + .bind(&signing_kp.public_key) 350 + .bind(&encrypted) 351 + .execute(db) 352 + .await 353 + .expect("insert relay_signing_key"); 354 + 355 + // Insert a claim_code row (required FK for pending_accounts). 356 + let claim_code = format!("TEST-{}", Uuid::new_v4()); 357 + sqlx::query( 358 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 359 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 360 + ) 361 + .bind(&claim_code) 362 + .execute(db) 363 + .await 364 + .expect("insert claim_code"); 365 + 366 + // Insert pending_account. 367 + let account_id = Uuid::new_v4().to_string(); 368 + let handle = format!("alice{}.example.com", &account_id[..8]); 369 + sqlx::query( 370 + "INSERT INTO pending_accounts \ 371 + (id, email, handle, tier, claim_code, created_at) \ 372 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 373 + ) 374 + .bind(&account_id) 375 + .bind(format!("alice{}@example.com", &account_id[..8])) 376 + .bind(&handle) 377 + .bind(&claim_code) 378 + .execute(db) 379 + .await 380 + .expect("insert pending_account"); 381 + 382 + // Insert a device (required FK for pending_sessions). 383 + let device_id = Uuid::new_v4().to_string(); 384 + sqlx::query( 385 + "INSERT INTO devices \ 386 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 387 + VALUES (?, ?, 'ios', 'test_pubkey', 'test_device_hash', datetime('now'), datetime('now'))", 388 + ) 389 + .bind(&device_id) 390 + .bind(&account_id) 391 + .execute(db) 392 + .await 393 + .expect("insert device"); 394 + 395 + // Generate pending session token. 396 + let mut token_bytes = [0u8; 32]; 397 + OsRng.fill_bytes(&mut token_bytes); 398 + let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 399 + let token_hash: String = Sha256::digest(token_bytes) 400 + .iter() 401 + .map(|b| format!("{b:02x}")) 402 + .collect(); 403 + 404 + // Insert pending_session. 405 + sqlx::query( 406 + "INSERT INTO pending_sessions \ 407 + (id, account_id, device_id, token_hash, created_at, expires_at) \ 408 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))", 409 + ) 410 + .bind(Uuid::new_v4().to_string()) 411 + .bind(&account_id) 412 + .bind(&device_id) 413 + .bind(&token_hash) 414 + .execute(db) 415 + .await 416 + .expect("insert pending_session"); 417 + 418 + TestSetup { 419 + session_token, 420 + signing_key_id: signing_kp.key_id.0, 421 + rotation_key_id: rotation_kp.key_id.0, 422 + account_id, 423 + handle, 424 + } 425 + } 426 + 427 + /// Create an AppState with TEST_MASTER_KEY set and plc_directory_url pointing to the mock. 428 + async fn test_state_for_did(plc_url: String) -> AppState { 429 + use common::Sensitive; 430 + use std::sync::Arc; 431 + use zeroize::Zeroizing; 432 + 433 + let base = test_state_with_plc_url(plc_url).await; 434 + let mut config = (*base.config).clone(); 435 + config.signing_key_master_key = Some(Sensitive(Zeroizing::new(TEST_MASTER_KEY))); 436 + AppState { 437 + config: Arc::new(config), 438 + db: base.db, 439 + http_client: base.http_client, 440 + } 441 + } 442 + 443 + /// Build a POST /v1/dids request with the given session token and body. 444 + fn create_did_request( 445 + session_token: &str, 446 + signing_key: &str, 447 + rotation_key: &str, 448 + ) -> Request<Body> { 449 + let body = serde_json::json!({ 450 + "signingKey": signing_key, 451 + "rotationKey": rotation_key, 452 + }); 453 + Request::builder() 454 + .method("POST") 455 + .uri("/v1/dids") 456 + .header("Authorization", format!("Bearer {session_token}")) 457 + .header("Content-Type", "application/json") 458 + .body(Body::from(body.to_string())) 459 + .unwrap() 460 + } 461 + 462 + // ── AC2.1: Valid request returns 200 with { did, status: "active" } ─────── 463 + 464 + /// MM-89.AC2.1, AC2.2, AC2.3, AC2.4, AC2.5: Happy path — full promotion 465 + #[tokio::test] 466 + async fn happy_path_promotes_account_and_returns_did() { 467 + let mock_server = MockServer::start().await; 468 + Mock::given(method("POST")) 469 + .and(path_regex(r"^/did:plc:[a-z2-7]+$")) 470 + .respond_with(ResponseTemplate::new(200)) 471 + .expect(1) 472 + .named("plc.directory genesis op") 473 + .mount(&mock_server) 474 + .await; 475 + 476 + let state = test_state_for_did(mock_server.uri()).await; 477 + let db = state.db.clone(); 478 + let setup = insert_test_data(&db).await; 479 + 480 + let app = crate::app::app(state); 481 + let response = app 482 + .oneshot(create_did_request( 483 + &setup.session_token, 484 + &setup.signing_key_id, 485 + &setup.rotation_key_id, 486 + )) 487 + .await 488 + .unwrap(); 489 + 490 + // AC2.1: 200 OK with did + status 491 + assert_eq!(response.status(), StatusCode::OK); 492 + let body: serde_json::Value = 493 + serde_json::from_slice(&axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap(); 494 + let did = body["did"].as_str().expect("did field"); 495 + assert!(did.starts_with("did:plc:"), "did should start with did:plc:"); 496 + assert_eq!(body["status"], "active"); 497 + 498 + // AC2.2: accounts row with null password_hash 499 + let (stored_email, stored_hash): (String, Option<String>) = 500 + sqlx::query_as("SELECT email, password_hash FROM accounts WHERE did = ?") 501 + .bind(did) 502 + .fetch_one(&db) 503 + .await 504 + .expect("accounts row should exist"); 505 + assert!(stored_hash.is_none(), "password_hash should be NULL"); 506 + assert!(stored_email.contains("alice"), "email should be set"); 507 + 508 + // AC2.3: did_documents row with non-empty document 509 + let (doc,): (String,) = 510 + sqlx::query_as("SELECT document FROM did_documents WHERE did = ?") 511 + .bind(did) 512 + .fetch_one(&db) 513 + .await 514 + .expect("did_documents row should exist"); 515 + assert!(!doc.is_empty(), "did_document should be non-empty"); 516 + 517 + // AC2.4: handles row 518 + let (handle_did,): (String,) = 519 + sqlx::query_as("SELECT did FROM handles WHERE did = ?") 520 + .bind(did) 521 + .fetch_one(&db) 522 + .await 523 + .expect("handles row should exist"); 524 + assert_eq!(handle_did, did); 525 + 526 + // AC2.5: pending_accounts and pending_sessions deleted 527 + let pending_count: i64 = 528 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_accounts WHERE id = ?") 529 + .bind(&setup.account_id) 530 + .fetch_one(&db) 531 + .await 532 + .unwrap(); 533 + assert_eq!(pending_count, 0, "pending_account should be deleted"); 534 + 535 + let session_count: i64 = 536 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_sessions WHERE account_id = ?") 537 + .bind(&setup.account_id) 538 + .fetch_one(&db) 539 + .await 540 + .unwrap(); 541 + assert_eq!(session_count, 0, "pending_sessions should be deleted"); 542 + } 543 + 544 + /// MM-89.AC2.6: Retry path — pending_did pre-set, plc.directory NOT called 545 + #[tokio::test] 546 + async fn retry_with_pending_did_skips_plc_directory() { 547 + let mock_server = MockServer::start().await; 548 + // Expect zero calls to plc.directory on a retry. 549 + // MockServer auto-verifies .expect(0) on drop — if plc.directory is called, 550 + // the mock panics and the test fails. 551 + Mock::given(method("POST")) 552 + .and(path_regex(r"^/did:plc:.*$")) 553 + .respond_with(ResponseTemplate::new(200)) 554 + .expect(0) // Must NOT be called 555 + .named("plc.directory (should not be called on retry)") 556 + .mount(&mock_server) 557 + .await; 558 + 559 + let state = test_state_for_did(mock_server.uri()).await; 560 + let db = state.db.clone(); 561 + let setup = insert_test_data(&db).await; 562 + 563 + // Simulate a partial-failure retry: set pending_did to any non-null value. 564 + // The handler checks `pending_did.is_some()` as a boolean flag to skip 565 + // plc.directory. It does NOT use the stored value — it always re-derives 566 + // the DID from the crypto function (deterministic from key + handle inputs). 567 + // So any syntactically valid DID string works here. 568 + let any_did = "did:plc:abcdefghijklmnopqrstuvwx"; 569 + sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 570 + .bind(any_did) 571 + .bind(&setup.account_id) 572 + .execute(&db) 573 + .await 574 + .expect("pre-store pending_did"); 575 + 576 + let app = crate::app::app(state); 577 + let response = app 578 + .oneshot(create_did_request( 579 + &setup.session_token, 580 + &setup.signing_key_id, 581 + &setup.rotation_key_id, 582 + )) 583 + .await 584 + .unwrap(); 585 + 586 + // The route skips plc.directory (enforced by .expect(0) above) and proceeds 587 + // to promote the account using the crypto-derived DID. Returns 200. 588 + assert_eq!( 589 + response.status(), 590 + StatusCode::OK, 591 + "retry should succeed with 200" 592 + ); 593 + } 594 + 595 + /// MM-89.AC2.7: Missing Authorization header returns 401 596 + #[tokio::test] 597 + async fn missing_auth_header_returns_401() { 598 + let state = test_state_with_plc_url("https://plc.directory".to_string()).await; 599 + let app = crate::app::app(state); 600 + 601 + let request = Request::builder() 602 + .method("POST") 603 + .uri("/v1/dids") 604 + .header("Content-Type", "application/json") 605 + .body(Body::from(r#"{"signingKey":"did:key:z...","rotationKey":"did:key:z..."}"#)) 606 + .unwrap(); 607 + 608 + let response = app.oneshot(request).await.unwrap(); 609 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 610 + } 611 + 612 + /// MM-89.AC2.8: Expired session token returns 401 613 + #[tokio::test] 614 + async fn expired_session_returns_401() { 615 + let state = test_state_for_did("https://plc.directory".to_string()).await; 616 + let db = state.db.clone(); 617 + let setup = insert_test_data(&db).await; 618 + 619 + // Manually expire the session. 620 + sqlx::query("UPDATE pending_sessions SET expires_at = datetime('now', '-1 hour') WHERE account_id = ?") 621 + .bind(&setup.account_id) 622 + .execute(&db) 623 + .await 624 + .expect("expire session"); 625 + 626 + let app = crate::app::app(state); 627 + let response = app 628 + .oneshot(create_did_request( 629 + &setup.session_token, 630 + &setup.signing_key_id, 631 + &setup.rotation_key_id, 632 + )) 633 + .await 634 + .unwrap(); 635 + 636 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 637 + } 638 + 639 + /// MM-89.AC2.9: signingKey not in relay_signing_keys returns 404 640 + #[tokio::test] 641 + async fn unknown_signing_key_returns_404() { 642 + let state = test_state_for_did("https://plc.directory".to_string()).await; 643 + let db = state.db.clone(); 644 + let setup = insert_test_data(&db).await; 645 + 646 + let app = crate::app::app(state); 647 + let response = app 648 + .oneshot(create_did_request( 649 + &setup.session_token, 650 + "did:key:zNONEXISTENT", // Not in relay_signing_keys 651 + &setup.rotation_key_id, 652 + )) 653 + .await 654 + .unwrap(); 655 + 656 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 657 + } 658 + 659 + /// MM-89.AC2.10: Account already promoted returns 409 DID_ALREADY_EXISTS 660 + /// 661 + /// The DID is deterministic from (rotation_key, signing_key, handle, service_endpoint). 662 + /// To reliably trigger 409, we: 663 + /// 1. First call promotes setup's account (deletes pending_accounts + pending_sessions). 664 + /// 2. Create a NEW pending account+session using the SAME signing key, rotation key, 665 + /// and handle as setup. Same inputs → same crypto-derived DID. 666 + /// 3. Second call: handler derives the same DID, finds the existing `accounts` row, 667 + /// returns 409 DID_ALREADY_EXISTS. 668 + #[tokio::test] 669 + async fn already_promoted_account_returns_409() { 670 + let mock_server = MockServer::start().await; 671 + Mock::given(method("POST")) 672 + .and(path_regex(r"^/did:plc:.*$")) 673 + .respond_with(ResponseTemplate::new(200)) 674 + .expect(1) // Only first call should hit plc.directory 675 + .mount(&mock_server) 676 + .await; 677 + 678 + let state = test_state_for_did(mock_server.uri()).await; 679 + let db = state.db.clone(); 680 + let setup = insert_test_data(&db).await; 681 + let signing_kp = crypto::generate_p256_keypair().expect("signing keypair"); 682 + let encrypted = 683 + crypto::encrypt_private_key(&signing_kp.private_key_bytes, &TEST_MASTER_KEY) 684 + .expect("encrypt key"); 685 + sqlx::query( 686 + "INSERT INTO relay_signing_keys \ 687 + (id, algorithm, public_key, private_key_encrypted, created_at) \ 688 + VALUES (?, 'p256', ?, ?, datetime('now'))", 689 + ) 690 + .bind(&signing_kp.key_id.0) 691 + .bind(&signing_kp.public_key) 692 + .bind(&encrypted) 693 + .execute(&db) 694 + .await 695 + .expect("insert second signing key"); 696 + 697 + // First call: promotes setup's account (deletes pending_accounts + pending_sessions). 698 + let app1 = crate::app::app(state); 699 + let resp1 = app1 700 + .oneshot(create_did_request( 701 + &setup.session_token, 702 + &setup.signing_key_id, 703 + &setup.rotation_key_id, 704 + )) 705 + .await 706 + .unwrap(); 707 + assert_eq!(resp1.status(), StatusCode::OK, "first call should succeed"); 708 + 709 + // setup's pending_accounts row is now deleted. Create a NEW pending account 710 + // with the SAME handle and signing key. Since pending_accounts.handle has no 711 + // unique constraint, we can reuse setup.handle here. 712 + let claim_code2 = format!("TEST-{}", Uuid::new_v4()); 713 + sqlx::query( 714 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 715 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 716 + ) 717 + .bind(&claim_code2) 718 + .execute(&db) 719 + .await 720 + .expect("claim_code2"); 721 + 722 + let account_id2 = Uuid::new_v4().to_string(); 723 + sqlx::query( 724 + "INSERT INTO pending_accounts \ 725 + (id, email, handle, tier, claim_code, created_at) \ 726 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 727 + ) 728 + .bind(&account_id2) 729 + .bind(format!("retry{}@example.com", &account_id2[..8])) 730 + .bind(&setup.handle) // same handle → same DID with same signing/rotation keys 731 + .bind(&claim_code2) 732 + .execute(&db) 733 + .await 734 + .expect("pending_account2"); 735 + 736 + let device_id2 = Uuid::new_v4().to_string(); 737 + sqlx::query( 738 + "INSERT INTO devices \ 739 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 740 + VALUES (?, ?, 'ios', 'retry_pubkey', 'retry_device_hash', datetime('now'), datetime('now'))", 741 + ) 742 + .bind(&device_id2) 743 + .bind(&account_id2) 744 + .execute(&db) 745 + .await 746 + .expect("device2"); 747 + 748 + let mut token_bytes2 = [0u8; 32]; 749 + OsRng.fill_bytes(&mut token_bytes2); 750 + let session_token2 = URL_SAFE_NO_PAD.encode(token_bytes2); 751 + let token_hash2: String = Sha256::digest(token_bytes2) 752 + .iter() 753 + .map(|b| format!("{b:02x}")) 754 + .collect(); 755 + sqlx::query( 756 + "INSERT INTO pending_sessions \ 757 + (id, account_id, device_id, token_hash, created_at, expires_at) \ 758 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))", 759 + ) 760 + .bind(Uuid::new_v4().to_string()) 761 + .bind(&account_id2) 762 + .bind(&device_id2) 763 + .bind(&token_hash2) 764 + .execute(&db) 765 + .await 766 + .expect("session2"); 767 + 768 + // Second call: same signing_key + rotation_key + handle → same DID. 769 + // accounts table already has this DID → handler returns 409. 770 + let state2 = test_state_for_did(mock_server.uri()).await; 771 + let app2 = crate::app::app(AppState { 772 + config: state2.config, 773 + db: db.clone(), 774 + http_client: state2.http_client, 775 + }); 776 + let resp2 = app2 777 + .oneshot(create_did_request( 778 + &session_token2, 779 + &setup.signing_key_id, // same signing key 780 + &setup.rotation_key_id, // same rotation key 781 + )) 782 + .await 783 + .unwrap(); 784 + assert_eq!(resp2.status(), StatusCode::CONFLICT, "should return 409 DID_ALREADY_EXISTS"); 785 + } 786 + 787 + /// MM-89.AC2.11: plc.directory returns non-2xx → 502 PLC_DIRECTORY_ERROR 788 + #[tokio::test] 789 + async fn plc_directory_error_returns_502() { 790 + let mock_server = MockServer::start().await; 791 + Mock::given(method("POST")) 792 + .and(path_regex(r"^/did:plc:.*$")) 793 + .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error")) 794 + .expect(1) 795 + .mount(&mock_server) 796 + .await; 797 + 798 + let state = test_state_for_did(mock_server.uri()).await; 799 + let db = state.db.clone(); 800 + let setup = insert_test_data(&db).await; 801 + 802 + let app = crate::app::app(state); 803 + let response = app 804 + .oneshot(create_did_request( 805 + &setup.session_token, 806 + &setup.signing_key_id, 807 + &setup.rotation_key_id, 808 + )) 809 + .await 810 + .unwrap(); 811 + 812 + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 813 + } 814 + }
+1
crates/relay/src/routes/mod.rs
··· 1 1 pub(crate) mod auth; 2 2 pub mod claim_codes; 3 3 pub mod create_account; 4 + pub mod create_did; 4 5 pub mod create_mobile_account; 5 6 pub mod create_signing_key; 6 7 pub mod describe_server;