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

feat(relay): implement POST /v1/accounts/mobile (MM-84)

Combined mobile account creation endpoint for the iOS identity wallet
onboarding flow. Atomically redeems a claim code, creates a pending
account, registers the device, and issues a pending session token in a
single transaction — with full rollback on any step failure.

- V007 migration: pending_sessions table (token_hash UNIQUE, FKs to
pending_accounts and devices) for pre-DID session tokens
- ClaimCodeRedeemed ErrorCode (409) to distinguish already-redeemed
codes from invalid/expired ones (404) per spec
- validate_handle and is_valid_platform promoted to pub(crate) for reuse
- Bruno collection entry for the new route

authored by malpercio.dev and committed by

Tangled 781c7757 5c5fefef

+909 -2
+21
bruno/create_mobile_account.bru
··· 1 + meta { 2 + name: Create Mobile Account 3 + type: http 4 + seq: 7 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/v1/accounts/mobile 9 + body: json 10 + auth: none 11 + } 12 + 13 + body:json { 14 + { 15 + "email": "alice@example.com", 16 + "handle": "alice.example.com", 17 + "devicePublicKey": "dGVzdC1rZXk=", 18 + "platform": "ios", 19 + "claimCode": "ABC123" 20 + } 21 + }
+5
crates/common/src/error.rs
··· 35 35 HandleTaken, 36 36 /// The handle string failed basic format validation. 37 37 InvalidHandle, 38 + /// A claim code that has already been redeemed is presented again. 39 + /// Clients should inform the user to obtain a different code. 40 + ClaimCodeRedeemed, 38 41 // TODO: add remaining codes from Appendix A as endpoints are implemented: 39 42 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION 40 43 // 401: INVALID_CREDENTIALS ··· 65 68 ErrorCode::AccountExists => 409, 66 69 ErrorCode::HandleTaken => 409, 67 70 ErrorCode::InvalidHandle => 400, 71 + ErrorCode::ClaimCodeRedeemed => 409, 68 72 } 69 73 } 70 74 } ··· 215 219 (ErrorCode::AccountExists, 409), 216 220 (ErrorCode::HandleTaken, 409), 217 221 (ErrorCode::InvalidHandle, 400), 222 + (ErrorCode::ClaimCodeRedeemed, 409), 218 223 ]; 219 224 for (code, expected) in cases { 220 225 assert_eq!(code.status_code(), expected, "wrong status for {code:?}");
+2
crates/relay/src/app.rs
··· 13 13 14 14 use crate::routes::claim_codes::claim_codes; 15 15 use crate::routes::create_account::create_account; 16 + use crate::routes::create_mobile_account::create_mobile_account; 16 17 use crate::routes::create_signing_key::create_signing_key; 17 18 use crate::routes::describe_server::describe_server; 18 19 use crate::routes::health::health; ··· 91 92 .route("/xrpc/:method", get(xrpc_handler).post(xrpc_handler)) 92 93 .route("/v1/accounts", post(create_account)) 93 94 .route("/v1/accounts/claim-codes", post(claim_codes)) 95 + .route("/v1/accounts/mobile", post(create_mobile_account)) 94 96 .route("/v1/devices", post(register_device)) 95 97 .route("/v1/relay/keys", post(create_signing_key)) 96 98 .layer(CorsLayer::permissive())
+28
crates/relay/src/db/migrations/V007__pending_sessions.sql
··· 1 + -- V007: Pending sessions for pre-DID mobile accounts 2 + -- 3 + -- pending_sessions holds session tokens for accounts that have completed 4 + -- mobile provisioning (POST /v1/accounts/mobile) but have not yet created 5 + -- their DID. These tokens authorize the DID-creation step. 6 + -- 7 + -- token_hash is SHA-256(session_token) stored as hex; the plaintext token is 8 + -- returned once at provisioning and never stored — matching the pattern used 9 + -- by devices.device_token_hash. 10 + -- 11 + -- Once DID creation completes, the pending_accounts row is promoted to accounts 12 + -- and a real sessions row is created; the pending_sessions row is deleted then. 13 + 14 + CREATE TABLE pending_sessions ( 15 + id TEXT NOT NULL, 16 + account_id TEXT NOT NULL REFERENCES pending_accounts (id), 17 + device_id TEXT NOT NULL REFERENCES devices (id), 18 + token_hash TEXT NOT NULL, -- SHA-256(session_token) as hex; token returned once 19 + created_at TEXT NOT NULL, 20 + expires_at TEXT NOT NULL, 21 + PRIMARY KEY (id) 22 + ); 23 + 24 + -- Each pending session must have a distinct token hash (defense-in-depth). 25 + CREATE UNIQUE INDEX idx_pending_sessions_token_hash ON pending_sessions (token_hash); 26 + 27 + -- Lookup by account (e.g., validate session for DID creation step). 28 + CREATE INDEX idx_pending_sessions_account_id ON pending_sessions (account_id);
+4
crates/relay/src/db/mod.rs
··· 52 52 version: 6, 53 53 sql: include_str!("migrations/V006__devices_v2.sql"), 54 54 }, 55 + Migration { 56 + version: 7, 57 + sql: include_str!("migrations/V007__pending_sessions.sql"), 58 + }, 55 59 ]; 56 60 57 61 /// Open a WAL-mode SQLite connection pool with a maximum of 1 connection.
+1 -1
crates/relay/src/routes/create_account.rs
··· 181 181 /// ATProto handles are domain names; this enforces only the least-controversial rules 182 182 /// (non-empty, ASCII, no whitespace, max length) to avoid incorrect rejections. 183 183 /// More thorough validation (segment structure, domain policy) is deferred to a later wave. 184 - fn validate_handle(handle: &str) -> Result<(), &'static str> { 184 + pub(crate) fn validate_handle(handle: &str) -> Result<(), &'static str> { 185 185 if handle.is_empty() { 186 186 return Err("handle must not be empty"); 187 187 }
+846
crates/relay/src/routes/create_mobile_account.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: JSON request body (email, handle, device_public_key, platform, claim_code), DB pool 4 + // Processes: platform validation → public key validation → email non-empty check → 5 + // handle format validation → email uniqueness (accounts + pending_accounts) → 6 + // handle uniqueness (handles + pending_accounts) → 7 + // ID + token generation → atomic transaction: 8 + // UPDATE claim_codes (redeem guard; 0 rows → SELECT to classify 404 vs 409) 9 + // INSERT pending_accounts (email/handle uniqueness enforced by unique indexes) 10 + // INSERT devices 11 + // INSERT pending_sessions 12 + // Returns: JSON { account_id, device_id, device_token, session_token, next_step } on success; 13 + // ApiError on all failure paths 14 + 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 + use serde::{Deserialize, Serialize}; 19 + use sha2::{Digest, Sha256}; 20 + use uuid::Uuid; 21 + 22 + use common::{ApiError, ErrorCode}; 23 + 24 + use crate::app::AppState; 25 + use crate::routes::create_account::validate_handle; 26 + use crate::routes::register_device::is_valid_platform; 27 + 28 + /// Maximum allowed length for a device public key string. 29 + const MAX_PUBLIC_KEY_LEN: usize = 512; 30 + 31 + #[derive(Deserialize)] 32 + #[serde(rename_all = "camelCase")] 33 + pub struct CreateMobileAccountRequest { 34 + email: String, 35 + handle: String, 36 + device_public_key: String, 37 + platform: String, 38 + claim_code: String, 39 + } 40 + 41 + #[derive(Serialize)] 42 + #[serde(rename_all = "camelCase")] 43 + pub struct CreateMobileAccountResponse { 44 + account_id: String, 45 + device_id: String, 46 + device_token: String, 47 + session_token: String, 48 + next_step: String, 49 + } 50 + 51 + pub async fn create_mobile_account( 52 + State(state): State<AppState>, 53 + Json(payload): Json<CreateMobileAccountRequest>, 54 + ) -> Result<(StatusCode, Json<CreateMobileAccountResponse>), ApiError> { 55 + // --- Validate platform --- 56 + if !is_valid_platform(&payload.platform) { 57 + return Err(ApiError::new( 58 + ErrorCode::InvalidClaim, 59 + "platform must be one of: ios, android, macos, linux, windows", 60 + )); 61 + } 62 + 63 + // --- Validate device_public_key --- 64 + if payload.device_public_key.is_empty() { 65 + return Err(ApiError::new( 66 + ErrorCode::InvalidClaim, 67 + "devicePublicKey must not be empty", 68 + )); 69 + } 70 + if payload.device_public_key.len() > MAX_PUBLIC_KEY_LEN { 71 + return Err(ApiError::new( 72 + ErrorCode::InvalidClaim, 73 + format!("devicePublicKey must be at most {MAX_PUBLIC_KEY_LEN} characters"), 74 + )); 75 + } 76 + 77 + // --- Validate email (basic non-empty check; format validation is deferred) --- 78 + if payload.email.is_empty() { 79 + return Err(ApiError::new( 80 + ErrorCode::InvalidClaim, 81 + "email must not be empty", 82 + )); 83 + } 84 + 85 + // --- Validate handle format --- 86 + if let Err(msg) = validate_handle(&payload.handle) { 87 + return Err(ApiError::new(ErrorCode::InvalidHandle, msg)); 88 + } 89 + 90 + // --- Email uniqueness: check accounts and pending_accounts in one query --- 91 + // Fast-path rejection before the INSERT to avoid consuming a claim code slot on a 92 + // predictable conflict. The unique indexes remain the authoritative enforcement. 93 + let email_taken: i64 = sqlx::query_scalar( 94 + "SELECT CAST( 95 + (EXISTS(SELECT 1 FROM accounts WHERE email = ?) 96 + OR EXISTS(SELECT 1 FROM pending_accounts WHERE email = ?)) 97 + AS INTEGER)", 98 + ) 99 + .bind(&payload.email) 100 + .bind(&payload.email) 101 + .fetch_one(&state.db) 102 + .await 103 + .map_err(|e| { 104 + tracing::error!(error = %e, "failed to check email uniqueness"); 105 + ApiError::new(ErrorCode::InternalError, "failed to create account") 106 + })?; 107 + 108 + if email_taken != 0 { 109 + return Err(ApiError::new( 110 + ErrorCode::AccountExists, 111 + "an account with this email already exists", 112 + )); 113 + } 114 + 115 + // --- Handle uniqueness: check handles and pending_accounts in one query --- 116 + let handle_taken: i64 = sqlx::query_scalar( 117 + "SELECT CAST( 118 + (EXISTS(SELECT 1 FROM handles WHERE handle = ?) 119 + OR EXISTS(SELECT 1 FROM pending_accounts WHERE handle = ?)) 120 + AS INTEGER)", 121 + ) 122 + .bind(&payload.handle) 123 + .bind(&payload.handle) 124 + .fetch_one(&state.db) 125 + .await 126 + .map_err(|e| { 127 + tracing::error!(error = %e, "failed to check handle uniqueness"); 128 + ApiError::new(ErrorCode::InternalError, "failed to create account") 129 + })?; 130 + 131 + if handle_taken != 0 { 132 + return Err(ApiError::new( 133 + ErrorCode::HandleTaken, 134 + "this handle is already claimed", 135 + )); 136 + } 137 + 138 + // --- Generate IDs and credentials --- 139 + // device_token / session_token: 32 random bytes → base64url (no padding) for the wire; 140 + // SHA-256 of the raw bytes → 64-char hex for the DB. 141 + // Plaintext tokens are returned once and never stored; future auth uses the hashes. 142 + let account_id = Uuid::new_v4().to_string(); 143 + let device_id = Uuid::new_v4().to_string(); 144 + let session_id = Uuid::new_v4().to_string(); 145 + 146 + let mut device_token_bytes = [0u8; 32]; 147 + OsRng.fill_bytes(&mut device_token_bytes); 148 + let device_token = URL_SAFE_NO_PAD.encode(device_token_bytes); 149 + let device_token_hash: String = Sha256::digest(device_token_bytes) 150 + .iter() 151 + .map(|b| format!("{b:02x}")) 152 + .collect(); 153 + 154 + let mut session_token_bytes = [0u8; 32]; 155 + OsRng.fill_bytes(&mut session_token_bytes); 156 + let session_token = URL_SAFE_NO_PAD.encode(session_token_bytes); 157 + let session_token_hash: String = Sha256::digest(session_token_bytes) 158 + .iter() 159 + .map(|b| format!("{b:02x}")) 160 + .collect(); 161 + 162 + // --- Atomically provision: redeem claim code + create account + register device + issue session --- 163 + provision_mobile_account( 164 + &state.db, 165 + ProvisionParams { 166 + claim_code: &payload.claim_code, 167 + account_id: &account_id, 168 + email: &payload.email, 169 + handle: &payload.handle, 170 + device_id: &device_id, 171 + platform: &payload.platform, 172 + public_key: &payload.device_public_key, 173 + device_token_hash: &device_token_hash, 174 + session_id: &session_id, 175 + session_token_hash: &session_token_hash, 176 + }, 177 + ) 178 + .await?; 179 + 180 + Ok(( 181 + StatusCode::CREATED, 182 + Json(CreateMobileAccountResponse { 183 + account_id, 184 + device_id, 185 + device_token, 186 + session_token, 187 + next_step: "did_creation".to_string(), 188 + }), 189 + )) 190 + } 191 + 192 + /// Parameters for [`provision_mobile_account`]. Grouped into a struct to keep the 193 + /// function signature under Clippy's `too_many_arguments` limit. 194 + struct ProvisionParams<'a> { 195 + claim_code: &'a str, 196 + account_id: &'a str, 197 + email: &'a str, 198 + handle: &'a str, 199 + device_id: &'a str, 200 + platform: &'a str, 201 + public_key: &'a str, 202 + device_token_hash: &'a str, 203 + session_id: &'a str, 204 + session_token_hash: &'a str, 205 + } 206 + 207 + /// Atomically redeem a claim code and create the account, device, and pending session. 208 + /// 209 + /// Steps inside the transaction: 210 + /// 1. UPDATE claim_codes with a WHERE guard to reject invalid/expired/redeemed codes. 211 + /// 2. If 0 rows_affected: SELECT to distinguish 404 (invalid/expired) from 409 (redeemed). 212 + /// 3. INSERT pending_accounts — email/handle uniqueness enforced by unique indexes. 213 + /// 4. INSERT devices — bound to the new pending account. 214 + /// 5. INSERT pending_sessions — issues a session token for the DID-creation step. 215 + /// 216 + /// On any failure after begin(), the transaction is dropped and SQLite rolls back all 217 + /// changes — the claim code remains unredeemed and no orphaned rows are created. 218 + #[tracing::instrument(skip(db, p), err, fields(claim_code = %p.claim_code))] 219 + async fn provision_mobile_account( 220 + db: &sqlx::SqlitePool, 221 + p: ProvisionParams<'_>, 222 + ) -> Result<(), ApiError> { 223 + let ProvisionParams { 224 + claim_code, 225 + account_id, 226 + email, 227 + handle, 228 + device_id, 229 + platform, 230 + public_key, 231 + device_token_hash, 232 + session_id, 233 + session_token_hash, 234 + } = p; 235 + let mut tx = db 236 + .begin() 237 + .await 238 + .inspect_err(|e| { 239 + tracing::error!(error = %e, "failed to begin mobile account transaction"); 240 + }) 241 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?; 242 + 243 + // Attempt to mark the claim code redeemed. The WHERE guard rejects invalid, expired, 244 + // and previously-redeemed codes in one atomic step — no separate SELECT needed for 245 + // the guard itself. A 0 rows_affected result is classified below. 246 + let result = sqlx::query( 247 + "UPDATE claim_codes \ 248 + SET redeemed_at = datetime('now') \ 249 + WHERE code = ? AND redeemed_at IS NULL AND expires_at > datetime('now')", 250 + ) 251 + .bind(claim_code) 252 + .execute(&mut *tx) 253 + .await 254 + .inspect_err(|e| { 255 + tracing::error!(error = %e, "failed to execute claim code redemption UPDATE"); 256 + }) 257 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?; 258 + 259 + if result.rows_affected() == 0 { 260 + // Distinguish: already-redeemed (409) vs. invalid or expired (404). 261 + let row: Option<(Option<String>,)> = 262 + sqlx::query_as("SELECT redeemed_at FROM claim_codes WHERE code = ?") 263 + .bind(claim_code) 264 + .fetch_optional(&mut *tx) 265 + .await 266 + .inspect_err(|e| { 267 + tracing::error!(error = %e, "failed to classify claim code status"); 268 + }) 269 + .map_err(|_| { 270 + ApiError::new(ErrorCode::InternalError, "failed to create account") 271 + })?; 272 + 273 + return Err(match row { 274 + Some((Some(_),)) => ApiError::new( 275 + ErrorCode::ClaimCodeRedeemed, 276 + "claim code has already been redeemed", 277 + ), 278 + _ => ApiError::new( 279 + ErrorCode::NotFound, 280 + "claim code is invalid or has expired", 281 + ), 282 + }); 283 + } 284 + 285 + // Insert the pending account. The claim_code FK references the just-updated claim_codes row. 286 + sqlx::query( 287 + "INSERT INTO pending_accounts (id, email, handle, tier, claim_code, created_at) \ 288 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 289 + ) 290 + .bind(account_id) 291 + .bind(email) 292 + .bind(handle) 293 + .bind(claim_code) 294 + .execute(&mut *tx) 295 + .await 296 + .inspect_err(|e| { 297 + tracing::error!(error = %e, "failed to insert pending_accounts row"); 298 + }) 299 + .map_err(|e| classify_pending_account_error(&e))?; 300 + 301 + // Register the device bound to this pending account. 302 + sqlx::query( 303 + "INSERT INTO devices \ 304 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 305 + VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))", 306 + ) 307 + .bind(device_id) 308 + .bind(account_id) 309 + .bind(platform) 310 + .bind(public_key) 311 + .bind(device_token_hash) 312 + .execute(&mut *tx) 313 + .await 314 + .inspect_err(|e| { 315 + tracing::error!(error = %e, "failed to insert device record"); 316 + }) 317 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?; 318 + 319 + // Issue a pending session token to authorize the DID-creation step. 320 + sqlx::query( 321 + "INSERT INTO pending_sessions \ 322 + (id, account_id, device_id, token_hash, created_at, expires_at) \ 323 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+24 hours'))", 324 + ) 325 + .bind(session_id) 326 + .bind(account_id) 327 + .bind(device_id) 328 + .bind(session_token_hash) 329 + .execute(&mut *tx) 330 + .await 331 + .inspect_err(|e| { 332 + tracing::error!(error = %e, "failed to insert pending session"); 333 + }) 334 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?; 335 + 336 + tx.commit() 337 + .await 338 + .inspect_err(|e| { 339 + tracing::error!(error = %e, "failed to commit mobile account transaction"); 340 + }) 341 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?; 342 + 343 + Ok(()) 344 + } 345 + 346 + /// Classify a unique constraint violation from pending_accounts into the appropriate ApiError. 347 + /// Returns InternalError for non-unique-violation errors. 348 + fn classify_pending_account_error(e: &sqlx::Error) -> ApiError { 349 + if let sqlx::Error::Database(db_err) = e { 350 + if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation { 351 + let msg = db_err.message(); 352 + if msg.contains("pending_accounts.email") { 353 + return ApiError::new( 354 + ErrorCode::AccountExists, 355 + "an account with this email already exists", 356 + ); 357 + } 358 + if msg.contains("pending_accounts.handle") { 359 + return ApiError::new( 360 + ErrorCode::HandleTaken, 361 + "this handle is already claimed", 362 + ); 363 + } 364 + } 365 + } 366 + ApiError::new(ErrorCode::InternalError, "failed to create account") 367 + } 368 + 369 + #[cfg(test)] 370 + mod tests { 371 + use axum::{ 372 + body::Body, 373 + http::{Request, StatusCode}, 374 + }; 375 + use tower::ServiceExt; 376 + 377 + use crate::app::{app, test_state}; 378 + 379 + // ── Helpers ─────────────────────────────────────────────────────────────── 380 + 381 + fn post_create_mobile_account(body: &str) -> Request<Body> { 382 + Request::builder() 383 + .method("POST") 384 + .uri("/v1/accounts/mobile") 385 + .header("Content-Type", "application/json") 386 + .body(Body::from(body.to_string())) 387 + .unwrap() 388 + } 389 + 390 + /// Seed a standalone (unlinked) claim code ready for mobile provisioning. 391 + /// Returns the claim code string. 392 + async fn seed_claim_code(db: &sqlx::SqlitePool) -> String { 393 + let code: String = uuid::Uuid::new_v4() 394 + .simple() 395 + .to_string() 396 + .chars() 397 + .take(8) 398 + .map(|c| c.to_ascii_uppercase()) 399 + .collect(); 400 + 401 + sqlx::query( 402 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 403 + VALUES (?, datetime('now', '+24 hours'), datetime('now'))", 404 + ) 405 + .bind(&code) 406 + .execute(db) 407 + .await 408 + .unwrap(); 409 + 410 + code 411 + } 412 + 413 + fn mobile_body(claim_code: &str) -> String { 414 + format!( 415 + r#"{{"email":"test@example.com","handle":"test.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios","claimCode":"{claim_code}"}}"# 416 + ) 417 + } 418 + 419 + // ── Happy path ──────────────────────────────────────────────────────────── 420 + 421 + #[tokio::test] 422 + async fn returns_201_with_correct_shape() { 423 + // MM-84.AC1: single POST completes account + device + session setup 424 + let state = test_state().await; 425 + let claim_code = seed_claim_code(&state.db).await; 426 + 427 + let response = app(state) 428 + .oneshot(post_create_mobile_account(&mobile_body(&claim_code))) 429 + .await 430 + .unwrap(); 431 + 432 + assert_eq!(response.status(), StatusCode::CREATED); 433 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 434 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 435 + 436 + assert!(json["accountId"].as_str().is_some(), "accountId must be present"); 437 + assert!(json["deviceId"].as_str().is_some(), "deviceId must be present"); 438 + assert!(json["deviceToken"].as_str().is_some(), "deviceToken must be present"); 439 + assert!(json["sessionToken"].as_str().is_some(), "sessionToken must be present"); 440 + assert_eq!(json["nextStep"], "did_creation"); 441 + } 442 + 443 + #[tokio::test] 444 + async fn all_ids_are_uuids() { 445 + let state = test_state().await; 446 + let claim_code = seed_claim_code(&state.db).await; 447 + 448 + let response = app(state) 449 + .oneshot(post_create_mobile_account(&mobile_body(&claim_code))) 450 + .await 451 + .unwrap(); 452 + 453 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 454 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 455 + 456 + uuid::Uuid::parse_str(json["accountId"].as_str().unwrap()) 457 + .expect("accountId must be a valid UUID"); 458 + uuid::Uuid::parse_str(json["deviceId"].as_str().unwrap()) 459 + .expect("deviceId must be a valid UUID"); 460 + } 461 + 462 + #[tokio::test] 463 + async fn tokens_are_base64url_43_chars() { 464 + let state = test_state().await; 465 + let claim_code = seed_claim_code(&state.db).await; 466 + 467 + let response = app(state) 468 + .oneshot(post_create_mobile_account(&mobile_body(&claim_code))) 469 + .await 470 + .unwrap(); 471 + 472 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 473 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 474 + 475 + for field in ["deviceToken", "sessionToken"] { 476 + let token = json[field].as_str().unwrap(); 477 + assert!( 478 + token.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), 479 + "{field} must be base64url without padding; got: {token}" 480 + ); 481 + assert_eq!( 482 + token.len(), 483 + 43, 484 + "{field} must be 43 chars (base64url of 32 bytes)" 485 + ); 486 + } 487 + } 488 + 489 + #[tokio::test] 490 + async fn all_rows_persisted_in_db() { 491 + // MM-84.AC1: transaction atomicity — all three rows must be written 492 + let state = test_state().await; 493 + let db = state.db.clone(); 494 + let claim_code = seed_claim_code(&state.db).await; 495 + 496 + let response = app(state) 497 + .oneshot(post_create_mobile_account(&mobile_body(&claim_code))) 498 + .await 499 + .unwrap(); 500 + 501 + assert_eq!(response.status(), StatusCode::CREATED); 502 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 503 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 504 + 505 + let account_id = json["accountId"].as_str().unwrap(); 506 + let device_id = json["deviceId"].as_str().unwrap(); 507 + 508 + // pending_accounts row 509 + let (email, handle, tier, code): (String, String, String, String) = sqlx::query_as( 510 + "SELECT email, handle, tier, claim_code FROM pending_accounts WHERE id = ?", 511 + ) 512 + .bind(account_id) 513 + .fetch_one(&db) 514 + .await 515 + .expect("pending_accounts row must exist"); 516 + 517 + assert_eq!(email, "test@example.com"); 518 + assert_eq!(handle, "test.example.com"); 519 + assert_eq!(tier, "free"); 520 + assert_eq!(code, claim_code); 521 + 522 + // devices row 523 + let (dev_account_id, platform, public_key): (String, String, String) = sqlx::query_as( 524 + "SELECT account_id, platform, public_key FROM devices WHERE id = ?", 525 + ) 526 + .bind(device_id) 527 + .fetch_one(&db) 528 + .await 529 + .expect("devices row must exist"); 530 + 531 + assert_eq!(dev_account_id, account_id); 532 + assert_eq!(platform, "ios"); 533 + assert_eq!(public_key, "dGVzdC1rZXk="); 534 + 535 + // pending_sessions row 536 + let (sess_account_id, sess_device_id): (String, String) = sqlx::query_as( 537 + "SELECT account_id, device_id FROM pending_sessions WHERE account_id = ?", 538 + ) 539 + .bind(account_id) 540 + .fetch_one(&db) 541 + .await 542 + .expect("pending_sessions row must exist"); 543 + 544 + assert_eq!(sess_account_id, account_id); 545 + assert_eq!(sess_device_id, device_id); 546 + } 547 + 548 + #[tokio::test] 549 + async fn claim_code_marked_redeemed() { 550 + let state = test_state().await; 551 + let db = state.db.clone(); 552 + let claim_code = seed_claim_code(&state.db).await; 553 + 554 + app(state) 555 + .oneshot(post_create_mobile_account(&mobile_body(&claim_code))) 556 + .await 557 + .unwrap(); 558 + 559 + let redeemed_at: Option<String> = 560 + sqlx::query_scalar("SELECT redeemed_at FROM claim_codes WHERE code = ?") 561 + .bind(&claim_code) 562 + .fetch_one(&db) 563 + .await 564 + .unwrap(); 565 + 566 + assert!(redeemed_at.is_some(), "claim code must have redeemed_at set"); 567 + } 568 + 569 + #[tokio::test] 570 + async fn token_hashes_are_sha256_of_tokens() { 571 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 572 + use sha2::{Digest, Sha256}; 573 + 574 + let state = test_state().await; 575 + let db = state.db.clone(); 576 + let claim_code = seed_claim_code(&state.db).await; 577 + 578 + let response = app(state) 579 + .oneshot(post_create_mobile_account(&mobile_body(&claim_code))) 580 + .await 581 + .unwrap(); 582 + 583 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 584 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 585 + 586 + let device_id = json["deviceId"].as_str().unwrap(); 587 + let account_id = json["accountId"].as_str().unwrap(); 588 + 589 + // device token hash 590 + let device_token_bytes = 591 + URL_SAFE_NO_PAD.decode(json["deviceToken"].as_str().unwrap()).unwrap(); 592 + let expected_device_hash: String = Sha256::digest(&device_token_bytes) 593 + .iter() 594 + .map(|b| format!("{b:02x}")) 595 + .collect(); 596 + 597 + let (stored_device_hash,): (String,) = 598 + sqlx::query_as("SELECT device_token_hash FROM devices WHERE id = ?") 599 + .bind(device_id) 600 + .fetch_one(&db) 601 + .await 602 + .unwrap(); 603 + assert_eq!(stored_device_hash, expected_device_hash, "device_token_hash mismatch"); 604 + 605 + // session token hash 606 + let session_token_bytes = 607 + URL_SAFE_NO_PAD.decode(json["sessionToken"].as_str().unwrap()).unwrap(); 608 + let expected_session_hash: String = Sha256::digest(&session_token_bytes) 609 + .iter() 610 + .map(|b| format!("{b:02x}")) 611 + .collect(); 612 + 613 + let (stored_session_hash,): (String,) = sqlx::query_as( 614 + "SELECT token_hash FROM pending_sessions WHERE account_id = ?", 615 + ) 616 + .bind(account_id) 617 + .fetch_one(&db) 618 + .await 619 + .unwrap(); 620 + assert_eq!(stored_session_hash, expected_session_hash, "session token_hash mismatch"); 621 + } 622 + 623 + // ── Claim code errors ───────────────────────────────────────────────────── 624 + 625 + #[tokio::test] 626 + async fn invalid_claim_code_returns_404() { 627 + // MM-84.AC2: invalid claim code returns 404 628 + let response = app(test_state().await) 629 + .oneshot(post_create_mobile_account( 630 + r#"{"email":"a@example.com","handle":"a.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios","claimCode":"INVALID"}"#, 631 + )) 632 + .await 633 + .unwrap(); 634 + 635 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 636 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 637 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 638 + assert_eq!(json["error"]["code"], "NOT_FOUND"); 639 + } 640 + 641 + #[tokio::test] 642 + async fn expired_claim_code_returns_404() { 643 + // MM-84.AC2: expired claim code returns 404 644 + let state = test_state().await; 645 + let code = "EXPRD001"; 646 + 647 + sqlx::query( 648 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 649 + VALUES (?, datetime('now', '-1 hour'), datetime('now', '-2 hours'))", 650 + ) 651 + .bind(code) 652 + .execute(&state.db) 653 + .await 654 + .unwrap(); 655 + 656 + let body = format!( 657 + r#"{{"email":"a@example.com","handle":"a.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios","claimCode":"{code}"}}"# 658 + ); 659 + let response = app(state) 660 + .oneshot(post_create_mobile_account(&body)) 661 + .await 662 + .unwrap(); 663 + 664 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 665 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 666 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 667 + assert_eq!(json["error"]["code"], "NOT_FOUND"); 668 + } 669 + 670 + #[tokio::test] 671 + async fn already_redeemed_claim_code_returns_409() { 672 + // MM-84.AC3: already-redeemed claim code returns 409 673 + let state = test_state().await; 674 + let claim_code = seed_claim_code(&state.db).await; 675 + let application = app(state); 676 + 677 + // First call succeeds. 678 + let first = application 679 + .clone() 680 + .oneshot(post_create_mobile_account(&mobile_body(&claim_code))) 681 + .await 682 + .unwrap(); 683 + assert_eq!(first.status(), StatusCode::CREATED); 684 + 685 + // Second call with same code must return 409. 686 + let second = application 687 + .oneshot(post_create_mobile_account( 688 + &format!(r#"{{"email":"other@example.com","handle":"other.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios","claimCode":"{claim_code}"}}"#) 689 + )) 690 + .await 691 + .unwrap(); 692 + 693 + assert_eq!(second.status(), StatusCode::CONFLICT); 694 + let body = axum::body::to_bytes(second.into_body(), 4096).await.unwrap(); 695 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 696 + assert_eq!(json["error"]["code"], "CLAIM_CODE_REDEEMED"); 697 + } 698 + 699 + // ── Atomicity ───────────────────────────────────────────────────────────── 700 + 701 + #[tokio::test] 702 + async fn duplicate_email_rolls_back_claim_code_redemption() { 703 + // MM-84.AC4: partial failure leaves no orphans — claim code must remain unredeemed 704 + let state = test_state().await; 705 + let db = state.db.clone(); 706 + let claim_code = seed_claim_code(&state.db).await; 707 + 708 + // Seed an existing pending account with the same email. 709 + let existing_code = seed_claim_code(&state.db).await; 710 + sqlx::query( 711 + "INSERT INTO pending_accounts (id, email, handle, tier, claim_code, created_at) \ 712 + VALUES (?, 'test@example.com', 'existing.example.com', 'free', ?, datetime('now'))", 713 + ) 714 + .bind(uuid::Uuid::new_v4().to_string()) 715 + .bind(&existing_code) 716 + .execute(&db) 717 + .await 718 + .unwrap(); 719 + 720 + let response = app(state) 721 + .oneshot(post_create_mobile_account(&mobile_body(&claim_code))) 722 + .await 723 + .unwrap(); 724 + 725 + assert_eq!(response.status(), StatusCode::CONFLICT); 726 + 727 + // Claim code must remain unredeemed. 728 + let redeemed_at: Option<String> = 729 + sqlx::query_scalar("SELECT redeemed_at FROM claim_codes WHERE code = ?") 730 + .bind(&claim_code) 731 + .fetch_one(&db) 732 + .await 733 + .unwrap(); 734 + assert!( 735 + redeemed_at.is_none(), 736 + "claim code must remain unredeemed after failed provisioning" 737 + ); 738 + } 739 + 740 + // ── Duplicate email / handle ─────────────────────────────────────────────── 741 + 742 + #[tokio::test] 743 + async fn duplicate_email_returns_409() { 744 + let state = test_state().await; 745 + let db = state.db.clone(); 746 + let code1 = seed_claim_code(&db).await; 747 + let code2 = seed_claim_code(&db).await; 748 + 749 + let resp1 = app(state.clone()) 750 + .oneshot(post_create_mobile_account(&format!( 751 + r#"{{"email":"dup@example.com","handle":"dup1.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios","claimCode":"{code1}"}}"# 752 + ))) 753 + .await 754 + .unwrap(); 755 + assert_eq!(resp1.status(), StatusCode::CREATED); 756 + 757 + let resp2 = app(state) 758 + .oneshot(post_create_mobile_account(&format!( 759 + r#"{{"email":"dup@example.com","handle":"dup2.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios","claimCode":"{code2}"}}"# 760 + ))) 761 + .await 762 + .unwrap(); 763 + 764 + assert_eq!(resp2.status(), StatusCode::CONFLICT); 765 + let body = axum::body::to_bytes(resp2.into_body(), 4096).await.unwrap(); 766 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 767 + assert_eq!(json["error"]["code"], "ACCOUNT_EXISTS"); 768 + } 769 + 770 + // ── Platform validation ─────────────────────────────────────────────────── 771 + 772 + #[tokio::test] 773 + async fn invalid_platform_returns_400() { 774 + let response = app(test_state().await) 775 + .oneshot(post_create_mobile_account( 776 + r#"{"email":"a@example.com","handle":"a.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"plan9","claimCode":"ABC123"}"#, 777 + )) 778 + .await 779 + .unwrap(); 780 + 781 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 782 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 783 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 784 + assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 785 + } 786 + 787 + // ── Public key validation ───────────────────────────────────────────────── 788 + 789 + #[tokio::test] 790 + async fn empty_public_key_returns_400() { 791 + let response = app(test_state().await) 792 + .oneshot(post_create_mobile_account( 793 + r#"{"email":"a@example.com","handle":"a.example.com","devicePublicKey":"","platform":"ios","claimCode":"ABC123"}"#, 794 + )) 795 + .await 796 + .unwrap(); 797 + 798 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 799 + } 800 + 801 + // ── Missing required fields ─────────────────────────────────────────────── 802 + 803 + #[tokio::test] 804 + async fn missing_email_returns_422() { 805 + let response = app(test_state().await) 806 + .oneshot(post_create_mobile_account( 807 + r#"{"handle":"a.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios","claimCode":"ABC123"}"#, 808 + )) 809 + .await 810 + .unwrap(); 811 + 812 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 813 + } 814 + 815 + #[tokio::test] 816 + async fn missing_claim_code_returns_422() { 817 + let response = app(test_state().await) 818 + .oneshot(post_create_mobile_account( 819 + r#"{"email":"a@example.com","handle":"a.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}"#, 820 + )) 821 + .await 822 + .unwrap(); 823 + 824 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 825 + } 826 + 827 + // ── DB failure ──────────────────────────────────────────────────────────── 828 + 829 + #[tokio::test] 830 + async fn closed_db_pool_returns_500() { 831 + let state = test_state().await; 832 + state.db.close().await; 833 + 834 + let response = app(state) 835 + .oneshot(post_create_mobile_account( 836 + r#"{"email":"a@example.com","handle":"a.example.com","devicePublicKey":"dGVzdC1rZXk=","platform":"ios","claimCode":"ABC123"}"#, 837 + )) 838 + .await 839 + .unwrap(); 840 + 841 + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); 842 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 843 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 844 + assert_eq!(json["error"]["code"], "INTERNAL_ERROR"); 845 + } 846 + }
+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_mobile_account; 4 5 pub mod create_signing_key; 5 6 pub mod describe_server; 6 7 pub mod health;
+1 -1
crates/relay/src/routes/register_device.rs
··· 100 100 )) 101 101 } 102 102 103 - fn is_valid_platform(platform: &str) -> bool { 103 + pub(crate) fn is_valid_platform(platform: &str) -> bool { 104 104 matches!(platform, "ios" | "android" | "macos" | "linux" | "windows") 105 105 } 106 106