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

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

Adds operator-authenticated account provisioning endpoint that creates
a pending account slot with a 24h claim code before DID assignment.

- V005 migration: pending_accounts staging table (id, email, handle,
tier, claim_code FK → claim_codes, created_at); unique indices on
email and handle
- New ErrorCode variants: AccountExists (409), HandleTaken (409),
InvalidHandle (400)
- POST /v1/accounts handler: auth → handle validation → email/handle
uniqueness across both pending and active tables → single-TX insert
into claim_codes + pending_accounts → 201 with {accountId, did: null,
claimCode, status: "pending"}
- 26 tests covering happy path, DB persistence, duplicate email/handle,
handle format, tier validation, missing fields, auth, and 500 path
- Bruno create_account.bru collection entry
- uuid v1 workspace dependency for account_id generation

authored by malpercio.dev and committed by

Tangled b118df6e 757067cb

+782 -2
+12
Cargo.lock
··· 1821 1821 "tracing", 1822 1822 "tracing-opentelemetry", 1823 1823 "tracing-subscriber", 1824 + "uuid", 1824 1825 "zeroize", 1825 1826 ] 1826 1827 ··· 2808 2809 version = "0.2.2" 2809 2810 source = "registry+https://github.com/rust-lang/crates.io-index" 2810 2811 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 2812 + 2813 + [[package]] 2814 + name = "uuid" 2815 + version = "1.22.0" 2816 + source = "registry+https://github.com/rust-lang/crates.io-index" 2817 + checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" 2818 + dependencies = [ 2819 + "getrandom 0.4.2", 2820 + "js-sys", 2821 + "wasm-bindgen", 2822 + ] 2811 2823 2812 2824 [[package]] 2813 2825 name = "valuable"
+1
Cargo.toml
··· 61 61 base64 = "0.21" 62 62 zeroize = "1" 63 63 subtle = "2" 64 + uuid = { version = "1", features = ["v4"] } 64 65 65 66 # Testing 66 67 tempfile = "3"
+23
bruno/create_account.bru
··· 1 + meta { 2 + name: Create Account 3 + type: http 4 + seq: 5 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/v1/accounts 9 + body: json 10 + auth: bearer 11 + } 12 + 13 + auth:bearer { 14 + token: {{adminToken}} 15 + } 16 + 17 + body:json { 18 + { 19 + "email": "alice@example.com", 20 + "handle": "alice.example.com", 21 + "tier": "free" 22 + } 23 + }
+11 -2
crates/common/src/error.rs
··· 29 29 /// error format, which uses PascalCase error names rather than SCREAMING_SNAKE_CASE. 30 30 #[serde(rename = "MethodNotImplemented")] 31 31 MethodNotImplemented, 32 + /// An account with the given email already exists (pending or active). 33 + AccountExists, 34 + /// The requested handle is already claimed by an active or pending account. 35 + HandleTaken, 36 + /// The handle string failed basic format validation. 37 + InvalidHandle, 32 38 // TODO: add remaining codes from Appendix A as endpoints are implemented: 33 39 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION 34 40 // 401: INVALID_CREDENTIALS 35 41 // 403: TIER_RESTRICTED, DIDWEB_REQUIRES_DOMAIN, SINGLE_DEVICE_TIER 36 42 // 404: DEVICE_NOT_FOUND, DID_NOT_FOUND, HANDLE_NOT_FOUND, NOT_IN_GRACE_PERIOD 37 - // 409: ACCOUNT_EXISTS, ACCOUNT_NOT_FOUND, DEVICE_LIMIT, DID_EXISTS, HANDLE_TAKEN, 43 + // 409: ACCOUNT_NOT_FOUND, DEVICE_LIMIT, DID_EXISTS, 38 44 // ROTATION_IN_PROGRESS, LEASE_HELD, MIGRATION_IN_PROGRESS, ACTIVE_MIGRATION 39 45 // 410: ALREADY_DELETED 40 - // 422: INVALID_KEY, INVALID_HANDLE, KEY_MISMATCH, DIDWEB_SELF_SERVICE 46 + // 422: INVALID_KEY, KEY_MISMATCH, DIDWEB_SELF_SERVICE 41 47 // 423: ACCOUNT_LOCKED 42 48 } 43 49 ··· 56 62 ErrorCode::ServiceUnavailable => 503, 57 63 ErrorCode::InternalError => 500, 58 64 ErrorCode::MethodNotImplemented => 501, 65 + ErrorCode::AccountExists => 409, 66 + ErrorCode::HandleTaken => 409, 67 + ErrorCode::InvalidHandle => 400, 59 68 } 60 69 } 61 70 }
+1
crates/relay/Cargo.toml
··· 29 29 crypto = { workspace = true } 30 30 rand_core = { workspace = true } 31 31 subtle = { workspace = true } 32 + uuid = { workspace = true } 32 33 zeroize = { workspace = true } 33 34 34 35 [dev-dependencies]
+2
crates/relay/src/app.rs
··· 12 12 use tracing_opentelemetry::OpenTelemetrySpanExt; 13 13 14 14 use crate::routes::claim_codes::claim_codes; 15 + use crate::routes::create_account::create_account; 15 16 use crate::routes::create_signing_key::create_signing_key; 16 17 use crate::routes::describe_server::describe_server; 17 18 use crate::routes::health::health; ··· 87 88 get(describe_server), 88 89 ) 89 90 .route("/xrpc/:method", get(xrpc_handler).post(xrpc_handler)) 91 + .route("/v1/accounts", post(create_account)) 90 92 .route("/v1/accounts/claim-codes", post(claim_codes)) 91 93 .route("/v1/relay/keys", post(create_signing_key)) 92 94 .layer(CorsLayer::permissive())
+25
crates/relay/src/db/migrations/V005__pending_accounts.sql
··· 1 + -- V005: Pre-provisioned (pending) accounts 2 + -- Applied in a single transaction by the migration runner. 3 + -- 4 + -- A pending account is an operator-created slot before the user claims it with a device. 5 + -- It records the desired email, handle, and tier alongside the claim code that the device 6 + -- must present to complete provisioning. The did is assigned only after device binding 7 + -- (a future wave), at which point the row is promoted to the accounts table. 8 + -- 9 + -- Status is implicit: every row in this table is "pending". 10 + -- After device binding, the row is deleted and a full accounts row is created. 11 + 12 + CREATE TABLE pending_accounts ( 13 + id TEXT NOT NULL, -- UUID v4; returned as account_id 14 + email TEXT NOT NULL, 15 + handle TEXT NOT NULL, 16 + tier TEXT NOT NULL, -- free | pro | business 17 + claim_code TEXT NOT NULL REFERENCES claim_codes (code), 18 + created_at TEXT NOT NULL, 19 + PRIMARY KEY (id) 20 + ); 21 + 22 + -- Uniqueness: an email or handle may not appear in both pending_accounts and accounts. 23 + -- The accounts table already has idx_accounts_email; these cover the pending side. 24 + CREATE UNIQUE INDEX idx_pending_accounts_email ON pending_accounts (email); 25 + CREATE UNIQUE INDEX idx_pending_accounts_handle ON pending_accounts (handle);
+4
crates/relay/src/db/mod.rs
··· 44 44 version: 4, 45 45 sql: include_str!("migrations/V004__claim_codes_invite.sql"), 46 46 }, 47 + Migration { 48 + version: 5, 49 + sql: include_str!("migrations/V005__pending_accounts.sql"), 50 + }, 47 51 ]; 48 52 49 53 /// Open a WAL-mode SQLite connection pool with a maximum of 1 connection.
+702
crates/relay/src/routes/create_account.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: Bearer token from Authorization header, JSON request body, config, DB pool 4 + // Processes: auth check → handle validation → tier validation → email uniqueness → 5 + // handle uniqueness → account_id generation → claim code generation → 6 + // DB transaction (claim_codes + pending_accounts insert) 7 + // Returns: JSON { account_id, did, claim_code, status } on success; ApiError on all failure paths 8 + 9 + use axum::{ 10 + extract::State, 11 + http::{HeaderMap, StatusCode}, 12 + response::Json, 13 + }; 14 + use rand_core::{OsRng, RngCore}; 15 + use serde::{Deserialize, Serialize}; 16 + use subtle::ConstantTimeEq; 17 + use uuid::Uuid; 18 + 19 + use common::{ApiError, ErrorCode}; 20 + 21 + use crate::app::AppState; 22 + 23 + const CODE_LEN: usize = 6; 24 + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 25 + const CLAIM_CODE_EXPIRES_IN_HOURS: u32 = 24; 26 + 27 + #[derive(Deserialize)] 28 + #[serde(rename_all = "camelCase")] 29 + pub struct CreateAccountRequest { 30 + email: String, 31 + handle: String, 32 + tier: String, 33 + } 34 + 35 + #[derive(Serialize)] 36 + #[serde(rename_all = "camelCase")] 37 + pub struct CreateAccountResponse { 38 + account_id: String, 39 + did: Option<String>, 40 + claim_code: String, 41 + status: String, 42 + } 43 + 44 + pub async fn create_account( 45 + State(state): State<AppState>, 46 + headers: HeaderMap, 47 + Json(payload): Json<CreateAccountRequest>, 48 + ) -> Result<(StatusCode, Json<CreateAccountResponse>), ApiError> { 49 + // --- Auth: require matching Bearer token --- 50 + let expected_token = state 51 + .config 52 + .admin_token 53 + .as_deref() 54 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "admin token not configured"))?; 55 + 56 + let auth_value = headers 57 + .get(axum::http::header::AUTHORIZATION) 58 + .and_then(|v| { 59 + v.to_str() 60 + .inspect_err(|_| { 61 + tracing::debug!( 62 + "Authorization header contains non-UTF-8 bytes; treating as absent" 63 + ); 64 + }) 65 + .ok() 66 + }) 67 + .unwrap_or(""); 68 + 69 + let provided_token = auth_value.strip_prefix("Bearer ").ok_or_else(|| { 70 + ApiError::new( 71 + ErrorCode::Unauthorized, 72 + "missing or invalid Authorization header", 73 + ) 74 + })?; 75 + 76 + if provided_token 77 + .as_bytes() 78 + .ct_eq(expected_token.as_bytes()) 79 + .unwrap_u8() 80 + != 1 81 + { 82 + return Err(ApiError::new(ErrorCode::Unauthorized, "invalid admin token")); 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 + // --- Validate tier --- 91 + if !is_valid_tier(&payload.tier) { 92 + return Err(ApiError::new( 93 + ErrorCode::InvalidClaim, 94 + "tier must be one of: free, pro, business", 95 + )); 96 + } 97 + 98 + // --- Email uniqueness: check accounts and pending_accounts --- 99 + let email_in_accounts: bool = sqlx::query_scalar( 100 + "SELECT EXISTS(SELECT 1 FROM accounts WHERE email = ?)", 101 + ) 102 + .bind(&payload.email) 103 + .fetch_one(&state.db) 104 + .await 105 + .map_err(|e| { 106 + tracing::error!(error = %e, "failed to check email uniqueness in accounts"); 107 + ApiError::new(ErrorCode::InternalError, "failed to create account") 108 + })?; 109 + 110 + let email_in_pending: bool = sqlx::query_scalar( 111 + "SELECT EXISTS(SELECT 1 FROM pending_accounts WHERE email = ?)", 112 + ) 113 + .bind(&payload.email) 114 + .fetch_one(&state.db) 115 + .await 116 + .map_err(|e| { 117 + tracing::error!(error = %e, "failed to check email uniqueness in pending_accounts"); 118 + ApiError::new(ErrorCode::InternalError, "failed to create account") 119 + })?; 120 + 121 + if email_in_accounts || email_in_pending { 122 + return Err(ApiError::new( 123 + ErrorCode::AccountExists, 124 + "an account with this email already exists", 125 + )); 126 + } 127 + 128 + // --- Handle uniqueness: check handles and pending_accounts --- 129 + let handle_in_handles: bool = sqlx::query_scalar( 130 + "SELECT EXISTS(SELECT 1 FROM handles WHERE handle = ?)", 131 + ) 132 + .bind(&payload.handle) 133 + .fetch_one(&state.db) 134 + .await 135 + .map_err(|e| { 136 + tracing::error!(error = %e, "failed to check handle uniqueness in handles"); 137 + ApiError::new(ErrorCode::InternalError, "failed to create account") 138 + })?; 139 + 140 + let handle_in_pending: bool = sqlx::query_scalar( 141 + "SELECT EXISTS(SELECT 1 FROM pending_accounts WHERE handle = ?)", 142 + ) 143 + .bind(&payload.handle) 144 + .fetch_one(&state.db) 145 + .await 146 + .map_err(|e| { 147 + tracing::error!(error = %e, "failed to check handle uniqueness in pending_accounts"); 148 + ApiError::new(ErrorCode::InternalError, "failed to create account") 149 + })?; 150 + 151 + if handle_in_handles || handle_in_pending { 152 + return Err(ApiError::new( 153 + ErrorCode::HandleTaken, 154 + "this handle is already claimed", 155 + )); 156 + } 157 + 158 + // --- Insert: generate account_id + claim code, write in one transaction --- 159 + // Retry up to 3 times on the rare event of a claim code collision. 160 + let account_id = Uuid::new_v4().to_string(); 161 + let offset = format!("+{CLAIM_CODE_EXPIRES_IN_HOURS} hours"); 162 + 163 + for attempt in 0..3_usize { 164 + let claim_code = generate_code(); 165 + match insert_pending_account( 166 + &state.db, 167 + &account_id, 168 + &payload.email, 169 + &payload.handle, 170 + &payload.tier, 171 + &claim_code, 172 + &offset, 173 + ) 174 + .await 175 + { 176 + Ok(()) => { 177 + return Ok(( 178 + StatusCode::CREATED, 179 + Json(CreateAccountResponse { 180 + account_id, 181 + did: None, 182 + claim_code, 183 + status: "pending".to_string(), 184 + }), 185 + )) 186 + } 187 + Err(e) if is_unique_violation(&e) => { 188 + tracing::warn!(attempt, "pending account insert conflict; retrying"); 189 + continue; 190 + } 191 + Err(e) => { 192 + tracing::error!(error = %e, "failed to insert pending account"); 193 + return Err(ApiError::new( 194 + ErrorCode::InternalError, 195 + "failed to create account", 196 + )); 197 + } 198 + } 199 + } 200 + 201 + Err(ApiError::new( 202 + ErrorCode::InternalError, 203 + "failed to generate unique claim code after retries", 204 + )) 205 + } 206 + 207 + /// Validate that a handle string passes basic format checks. 208 + /// ATProto handles are domain names; this enforces only the least-controversial rules 209 + /// (non-empty, ASCII, no whitespace, max length) to avoid incorrect rejections. 210 + /// More thorough validation (segment structure, domain policy) is deferred to a later wave. 211 + fn validate_handle(handle: &str) -> Result<(), &'static str> { 212 + if handle.is_empty() { 213 + return Err("handle must not be empty"); 214 + } 215 + if handle.len() > 253 { 216 + return Err("handle must be at most 253 characters"); 217 + } 218 + if !handle.is_ascii() { 219 + return Err("handle must contain only ASCII characters"); 220 + } 221 + if handle.chars().any(|c| c.is_ascii_whitespace()) { 222 + return Err("handle must not contain whitespace"); 223 + } 224 + Ok(()) 225 + } 226 + 227 + fn is_valid_tier(tier: &str) -> bool { 228 + matches!(tier, "free" | "pro" | "business") 229 + } 230 + 231 + /// Generate a single 6-character uppercase alphanumeric claim code. 232 + fn generate_code() -> String { 233 + let mut buf = [0u8; CODE_LEN]; 234 + OsRng.fill_bytes(&mut buf); 235 + buf.iter() 236 + .map(|&b| CHARSET[(b as usize) % CHARSET.len()] as char) 237 + .collect() 238 + } 239 + 240 + /// Insert a claim code and its associated pending account in a single transaction. 241 + async fn insert_pending_account( 242 + db: &sqlx::SqlitePool, 243 + account_id: &str, 244 + email: &str, 245 + handle: &str, 246 + tier: &str, 247 + claim_code: &str, 248 + expires_offset: &str, 249 + ) -> Result<(), sqlx::Error> { 250 + let mut tx = db.begin().await.inspect_err(|e| { 251 + tracing::error!(error = %e, "failed to begin pending_account transaction"); 252 + })?; 253 + 254 + sqlx::query( 255 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 256 + VALUES (?, datetime('now', ?), datetime('now'))", 257 + ) 258 + .bind(claim_code) 259 + .bind(expires_offset) 260 + .execute(&mut *tx) 261 + .await?; 262 + 263 + sqlx::query( 264 + "INSERT INTO pending_accounts (id, email, handle, tier, claim_code, created_at) \ 265 + VALUES (?, ?, ?, ?, ?, datetime('now'))", 266 + ) 267 + .bind(account_id) 268 + .bind(email) 269 + .bind(handle) 270 + .bind(tier) 271 + .bind(claim_code) 272 + .execute(&mut *tx) 273 + .await?; 274 + 275 + tx.commit().await.inspect_err(|e| { 276 + tracing::error!(error = %e, "failed to commit pending_account transaction"); 277 + })?; 278 + 279 + Ok(()) 280 + } 281 + 282 + fn is_unique_violation(e: &sqlx::Error) -> bool { 283 + matches!( 284 + e, 285 + sqlx::Error::Database(db_err) 286 + if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation 287 + ) 288 + } 289 + 290 + #[cfg(test)] 291 + mod tests { 292 + use std::sync::Arc; 293 + 294 + use axum::{ 295 + body::Body, 296 + http::{Request, StatusCode}, 297 + }; 298 + use tower::ServiceExt; 299 + 300 + use crate::app::{app, test_state, AppState}; 301 + 302 + // ── Helpers ─────────────────────────────────────────────────────────────── 303 + 304 + async fn test_state_with_admin_token() -> AppState { 305 + let base = test_state().await; 306 + let mut config = (*base.config).clone(); 307 + config.admin_token = Some("test-admin-token".to_string()); 308 + AppState { 309 + config: Arc::new(config), 310 + db: base.db, 311 + } 312 + } 313 + 314 + fn post_create_account(body: &str, bearer: Option<&str>) -> Request<Body> { 315 + let mut builder = Request::builder() 316 + .method("POST") 317 + .uri("/v1/accounts") 318 + .header("Content-Type", "application/json"); 319 + if let Some(token) = bearer { 320 + builder = builder.header("Authorization", format!("Bearer {token}")); 321 + } 322 + builder.body(Body::from(body.to_string())).unwrap() 323 + } 324 + 325 + // ── Happy path ──────────────────────────────────────────────────────────── 326 + 327 + #[tokio::test] 328 + async fn returns_201_with_correct_shape() { 329 + // MM-83.AC1: POST creates account and returns claim code 330 + let response = app(test_state_with_admin_token().await) 331 + .oneshot(post_create_account( 332 + r#"{"email":"alice@example.com","handle":"alice.example.com","tier":"free"}"#, 333 + Some("test-admin-token"), 334 + )) 335 + .await 336 + .unwrap(); 337 + 338 + assert_eq!(response.status(), StatusCode::CREATED); 339 + let body = axum::body::to_bytes(response.into_body(), 4096) 340 + .await 341 + .unwrap(); 342 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 343 + assert!(json["accountId"].as_str().is_some(), "accountId must be present"); 344 + assert_eq!(json["did"], serde_json::Value::Null, "did must be null"); 345 + assert!(json["claimCode"].as_str().is_some(), "claimCode must be present"); 346 + assert_eq!(json["status"], "pending"); 347 + } 348 + 349 + #[tokio::test] 350 + async fn claim_code_is_6_char_uppercase_alphanumeric() { 351 + // MM-83.AC1: claim code format 352 + let response = app(test_state_with_admin_token().await) 353 + .oneshot(post_create_account( 354 + r#"{"email":"bob@example.com","handle":"bob.example.com","tier":"pro"}"#, 355 + Some("test-admin-token"), 356 + )) 357 + .await 358 + .unwrap(); 359 + 360 + let body = axum::body::to_bytes(response.into_body(), 4096) 361 + .await 362 + .unwrap(); 363 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 364 + let code = json["claimCode"].as_str().unwrap(); 365 + assert_eq!(code.len(), 6, "claim code must be 6 chars"); 366 + assert!( 367 + code.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()), 368 + "claim code must be uppercase alphanumeric, got: {code}" 369 + ); 370 + } 371 + 372 + #[tokio::test] 373 + async fn records_persisted_in_db() { 374 + // MM-83.AC1: account and claim code stored in DB 375 + let state = test_state_with_admin_token().await; 376 + let db = state.db.clone(); 377 + 378 + let response = app(state) 379 + .oneshot(post_create_account( 380 + r#"{"email":"charlie@example.com","handle":"charlie.example.com","tier":"business"}"#, 381 + Some("test-admin-token"), 382 + )) 383 + .await 384 + .unwrap(); 385 + 386 + assert_eq!(response.status(), StatusCode::CREATED); 387 + let body = axum::body::to_bytes(response.into_body(), 4096) 388 + .await 389 + .unwrap(); 390 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 391 + let account_id = json["accountId"].as_str().unwrap(); 392 + let claim_code = json["claimCode"].as_str().unwrap(); 393 + 394 + // pending_accounts row 395 + let row: (String, String, String, String) = sqlx::query_as( 396 + "SELECT email, handle, tier, claim_code FROM pending_accounts WHERE id = ?", 397 + ) 398 + .bind(account_id) 399 + .fetch_one(&db) 400 + .await 401 + .expect("pending_accounts row must exist"); 402 + 403 + assert_eq!(row.0, "charlie@example.com"); 404 + assert_eq!(row.1, "charlie.example.com"); 405 + assert_eq!(row.2, "business"); 406 + assert_eq!(row.3, claim_code); 407 + 408 + // claim_codes row with redeemed_at NULL and ~24h expiry 409 + let within_window: bool = sqlx::query_scalar( 410 + "SELECT ABS(strftime('%s', expires_at) - strftime('%s', datetime('now', '+24 hours'))) < 5 \ 411 + FROM claim_codes WHERE code = ?", 412 + ) 413 + .bind(claim_code) 414 + .fetch_one(&db) 415 + .await 416 + .unwrap(); 417 + assert!(within_window, "claim code must expire approximately 24h from now"); 418 + } 419 + 420 + // ── Duplicate email ─────────────────────────────────────────────────────── 421 + 422 + #[tokio::test] 423 + async fn duplicate_email_in_pending_returns_409() { 424 + // MM-83.AC2: duplicate email returns 409 Conflict 425 + let state = test_state_with_admin_token().await; 426 + let app = app(state); 427 + 428 + let first = app 429 + .clone() 430 + .oneshot(post_create_account( 431 + r#"{"email":"dup@example.com","handle":"dup1.example.com","tier":"free"}"#, 432 + Some("test-admin-token"), 433 + )) 434 + .await 435 + .unwrap(); 436 + assert_eq!(first.status(), StatusCode::CREATED); 437 + 438 + let second = app 439 + .oneshot(post_create_account( 440 + r#"{"email":"dup@example.com","handle":"dup2.example.com","tier":"free"}"#, 441 + Some("test-admin-token"), 442 + )) 443 + .await 444 + .unwrap(); 445 + assert_eq!(second.status(), StatusCode::CONFLICT); 446 + } 447 + 448 + #[tokio::test] 449 + async fn duplicate_email_in_accounts_returns_409() { 450 + // MM-83.AC2: email already used by a fully-provisioned account also returns 409 451 + let state = test_state_with_admin_token().await; 452 + 453 + // Seed a fully-provisioned account directly. 454 + sqlx::query( 455 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 456 + VALUES ('did:plc:existing', 'existing@example.com', 'hash', datetime('now'), datetime('now'))", 457 + ) 458 + .execute(&state.db) 459 + .await 460 + .unwrap(); 461 + 462 + let response = app(state) 463 + .oneshot(post_create_account( 464 + r#"{"email":"existing@example.com","handle":"new.example.com","tier":"free"}"#, 465 + Some("test-admin-token"), 466 + )) 467 + .await 468 + .unwrap(); 469 + 470 + assert_eq!(response.status(), StatusCode::CONFLICT); 471 + } 472 + 473 + // ── Duplicate handle ────────────────────────────────────────────────────── 474 + 475 + #[tokio::test] 476 + async fn duplicate_handle_in_pending_returns_409() { 477 + let state = test_state_with_admin_token().await; 478 + let app = app(state); 479 + 480 + let first = app 481 + .clone() 482 + .oneshot(post_create_account( 483 + r#"{"email":"h1@example.com","handle":"taken.example.com","tier":"free"}"#, 484 + Some("test-admin-token"), 485 + )) 486 + .await 487 + .unwrap(); 488 + assert_eq!(first.status(), StatusCode::CREATED); 489 + 490 + let second = app 491 + .oneshot(post_create_account( 492 + r#"{"email":"h2@example.com","handle":"taken.example.com","tier":"free"}"#, 493 + Some("test-admin-token"), 494 + )) 495 + .await 496 + .unwrap(); 497 + assert_eq!(second.status(), StatusCode::CONFLICT); 498 + } 499 + 500 + // ── Handle validation ───────────────────────────────────────────────────── 501 + 502 + #[tokio::test] 503 + async fn empty_handle_returns_400() { 504 + // MM-83.AC3: invalid handle format returns 400 505 + let response = app(test_state_with_admin_token().await) 506 + .oneshot(post_create_account( 507 + r#"{"email":"x@example.com","handle":"","tier":"free"}"#, 508 + Some("test-admin-token"), 509 + )) 510 + .await 511 + .unwrap(); 512 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 513 + } 514 + 515 + #[tokio::test] 516 + async fn non_ascii_handle_returns_400() { 517 + let response = app(test_state_with_admin_token().await) 518 + .oneshot(post_create_account( 519 + r#"{"email":"x@example.com","handle":"älice.example.com","tier":"free"}"#, 520 + Some("test-admin-token"), 521 + )) 522 + .await 523 + .unwrap(); 524 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 525 + } 526 + 527 + #[tokio::test] 528 + async fn handle_with_whitespace_returns_400() { 529 + let response = app(test_state_with_admin_token().await) 530 + .oneshot(post_create_account( 531 + r#"{"email":"x@example.com","handle":"alice example.com","tier":"free"}"#, 532 + Some("test-admin-token"), 533 + )) 534 + .await 535 + .unwrap(); 536 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 537 + } 538 + 539 + #[tokio::test] 540 + async fn handle_exceeding_253_chars_returns_400() { 541 + let long_handle = "a".repeat(254); 542 + let body = format!( 543 + r#"{{"email":"x@example.com","handle":"{long_handle}","tier":"free"}}"# 544 + ); 545 + let response = app(test_state_with_admin_token().await) 546 + .oneshot(post_create_account(&body, Some("test-admin-token"))) 547 + .await 548 + .unwrap(); 549 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 550 + } 551 + 552 + // ── Tier validation ─────────────────────────────────────────────────────── 553 + 554 + #[tokio::test] 555 + async fn invalid_tier_returns_400() { 556 + let response = app(test_state_with_admin_token().await) 557 + .oneshot(post_create_account( 558 + r#"{"email":"x@example.com","handle":"x.example.com","tier":"enterprise"}"#, 559 + Some("test-admin-token"), 560 + )) 561 + .await 562 + .unwrap(); 563 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 564 + } 565 + 566 + // ── Missing required fields ─────────────────────────────────────────────── 567 + 568 + #[tokio::test] 569 + async fn missing_email_returns_422() { 570 + let response = app(test_state_with_admin_token().await) 571 + .oneshot(post_create_account( 572 + r#"{"handle":"x.example.com","tier":"free"}"#, 573 + Some("test-admin-token"), 574 + )) 575 + .await 576 + .unwrap(); 577 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 578 + } 579 + 580 + #[tokio::test] 581 + async fn missing_handle_returns_422() { 582 + let response = app(test_state_with_admin_token().await) 583 + .oneshot(post_create_account( 584 + r#"{"email":"x@example.com","tier":"free"}"#, 585 + Some("test-admin-token"), 586 + )) 587 + .await 588 + .unwrap(); 589 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 590 + } 591 + 592 + #[tokio::test] 593 + async fn missing_tier_returns_422() { 594 + let response = app(test_state_with_admin_token().await) 595 + .oneshot(post_create_account( 596 + r#"{"email":"x@example.com","handle":"x.example.com"}"#, 597 + Some("test-admin-token"), 598 + )) 599 + .await 600 + .unwrap(); 601 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 602 + } 603 + 604 + // ── Auth ────────────────────────────────────────────────────────────────── 605 + 606 + #[tokio::test] 607 + async fn missing_authorization_header_returns_401() { 608 + // MM-83.AC auth 609 + let response = app(test_state_with_admin_token().await) 610 + .oneshot(post_create_account( 611 + r#"{"email":"x@example.com","handle":"x.example.com","tier":"free"}"#, 612 + None, 613 + )) 614 + .await 615 + .unwrap(); 616 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 617 + } 618 + 619 + #[tokio::test] 620 + async fn wrong_bearer_token_returns_401() { 621 + let response = app(test_state_with_admin_token().await) 622 + .oneshot(post_create_account( 623 + r#"{"email":"x@example.com","handle":"x.example.com","tier":"free"}"#, 624 + Some("wrong-token"), 625 + )) 626 + .await 627 + .unwrap(); 628 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 629 + } 630 + 631 + #[tokio::test] 632 + async fn admin_token_not_configured_returns_401() { 633 + let response = app(test_state().await) 634 + .oneshot(post_create_account( 635 + r#"{"email":"x@example.com","handle":"x.example.com","tier":"free"}"#, 636 + Some("test-admin-token"), 637 + )) 638 + .await 639 + .unwrap(); 640 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 641 + } 642 + 643 + #[tokio::test] 644 + async fn closed_db_pool_returns_500() { 645 + let state = test_state_with_admin_token().await; 646 + state.db.close().await; 647 + 648 + let response = app(state) 649 + .oneshot(post_create_account( 650 + r#"{"email":"x@example.com","handle":"x.example.com","tier":"free"}"#, 651 + Some("test-admin-token"), 652 + )) 653 + .await 654 + .unwrap(); 655 + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); 656 + } 657 + 658 + // ── Pure unit tests ─────────────────────────────────────────────────────── 659 + 660 + #[test] 661 + fn validate_handle_rejects_empty() { 662 + assert!(super::validate_handle("").is_err()); 663 + } 664 + 665 + #[test] 666 + fn validate_handle_rejects_non_ascii() { 667 + assert!(super::validate_handle("älice.example.com").is_err()); 668 + } 669 + 670 + #[test] 671 + fn validate_handle_rejects_whitespace() { 672 + assert!(super::validate_handle("alice example.com").is_err()); 673 + assert!(super::validate_handle("alice\t.example.com").is_err()); 674 + } 675 + 676 + #[test] 677 + fn validate_handle_rejects_too_long() { 678 + assert!(super::validate_handle(&"a".repeat(254)).is_err()); 679 + } 680 + 681 + #[test] 682 + fn validate_handle_accepts_valid_handles() { 683 + assert!(super::validate_handle("alice.example.com").is_ok()); 684 + assert!(super::validate_handle("malpercio.dev").is_ok()); 685 + assert!(super::validate_handle("a.b").is_ok()); 686 + assert!(super::validate_handle(&"a".repeat(253)).is_ok()); 687 + } 688 + 689 + #[test] 690 + fn is_valid_tier_accepts_known_tiers() { 691 + assert!(super::is_valid_tier("free")); 692 + assert!(super::is_valid_tier("pro")); 693 + assert!(super::is_valid_tier("business")); 694 + } 695 + 696 + #[test] 697 + fn is_valid_tier_rejects_unknown() { 698 + assert!(!super::is_valid_tier("enterprise")); 699 + assert!(!super::is_valid_tier("")); 700 + assert!(!super::is_valid_tier("Free")); // case-sensitive 701 + } 702 + }
+1
crates/relay/src/routes/mod.rs
··· 1 1 pub mod claim_codes; 2 + pub mod create_account; 2 3 pub mod create_signing_key; 3 4 pub mod describe_server; 4 5 pub mod health;