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

feat(relay): implement POST /v1/accounts/claim-codes (MM-86)

Adds operator-authenticated endpoint for generating batch invite codes
before account creation exists. Fixes the Wave 1 schema which incorrectly
required a NOT NULL DID FK on claim_codes, making pre-account invite codes
structurally impossible.

- V004 migration: recreates claim_codes without did FK; adds expires_at index
- POST /v1/accounts/claim-codes: Bearer-auth, count 1–10, configurable expiry
- 6-char uppercase alphanumeric codes via OsRng, batch-inserted in one tx
- Status derived from redeemed_at/expires_at columns (no status enum)
- 15 handler tests covering happy path, format, persistence, validation, auth

authored by malpercio.dev and committed by

Tangled 6dac75c1 a7a957e2

+520 -5
+1
Cargo.lock
··· 1808 1808 "opentelemetry", 1809 1809 "opentelemetry-otlp", 1810 1810 "opentelemetry_sdk", 1811 + "rand_core", 1811 1812 "serde", 1812 1813 "serde_json", 1813 1814 "sqlx",
+1
crates/relay/Cargo.toml
··· 27 27 serde = { workspace = true } 28 28 sqlx = { workspace = true } 29 29 crypto = { workspace = true } 30 + rand_core = { workspace = true } 30 31 subtle = { workspace = true } 31 32 zeroize = { workspace = true } 32 33
+2
crates/relay/src/app.rs
··· 11 11 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 12 12 use tracing_opentelemetry::OpenTelemetrySpanExt; 13 13 14 + use crate::routes::claim_codes::claim_codes; 14 15 use crate::routes::create_signing_key::create_signing_key; 15 16 use crate::routes::describe_server::describe_server; 16 17 use crate::routes::health::health; ··· 86 87 get(describe_server), 87 88 ) 88 89 .route("/xrpc/:method", get(xrpc_handler).post(xrpc_handler)) 90 + .route("/v1/accounts/claim-codes", post(claim_codes)) 89 91 .route("/v1/relay/keys", post(create_signing_key)) 90 92 .layer(CorsLayer::permissive()) 91 93 .layer(TraceLayer::new_for_http().make_span_with(OtelMakeSpan))
+27
crates/relay/src/db/migrations/V004__claim_codes_invite.sql
··· 1 + -- V004: Redesign claim_codes for invite-code use case 2 + -- 3 + -- The Wave 1 schema (V002) required a NOT NULL DID FK to accounts, which prevents 4 + -- generating invite codes before an account exists. This migration recreates the 5 + -- table for operator-generated invite codes issued prior to account creation. 6 + -- 7 + -- Status is derived rather than stored: 8 + -- pending : redeemed_at IS NULL AND expires_at > datetime('now') 9 + -- redeemed : redeemed_at IS NOT NULL 10 + -- expired : redeemed_at IS NULL AND expires_at <= datetime('now') 11 + -- 12 + -- Production data loss: none (table was empty at time of migration; v0.1 pre-launch). 13 + 14 + ALTER TABLE claim_codes RENAME TO claim_codes_v1; 15 + 16 + CREATE TABLE claim_codes ( 17 + code TEXT NOT NULL, 18 + expires_at TEXT NOT NULL, 19 + created_at TEXT NOT NULL, 20 + redeemed_at TEXT, 21 + PRIMARY KEY (code) 22 + ); 23 + 24 + -- Supports expiry sweeps and redemption validity checks. 25 + CREATE INDEX idx_claim_codes_expires_at ON claim_codes (expires_at); 26 + 27 + DROP TABLE claim_codes_v1;
+10 -5
crates/relay/src/db/mod.rs
··· 40 40 version: 3, 41 41 sql: include_str!("migrations/V003__relay_signing_keys.sql"), 42 42 }, 43 + Migration { 44 + version: 4, 45 + sql: include_str!("migrations/V004__claim_codes_invite.sql"), 46 + }, 43 47 ]; 44 48 45 49 /// Open a WAL-mode SQLite connection pool with a maximum of 1 connection. ··· 593 597 ); 594 598 } 595 599 596 - /// EXPLAIN QUERY PLAN must show idx_claim_codes_did for a WHERE did = ? query. 600 + /// EXPLAIN QUERY PLAN must show idx_claim_codes_expires_at for a WHERE expires_at query. 601 + /// (V004 removed the did column; the index is now on expires_at for expiry sweeps.) 597 602 #[tokio::test] 598 - async fn v002_index_claim_codes_did_used() { 603 + async fn v004_index_claim_codes_expires_at_used() { 599 604 let pool = in_memory_pool().await; 600 605 run_migrations(&pool).await.unwrap(); 601 606 602 607 let plan: Vec<(i64, i64, i64, String)> = sqlx::query_as( 603 - "EXPLAIN QUERY PLAN SELECT * FROM claim_codes WHERE did = 'did:plc:aaa'", 608 + "EXPLAIN QUERY PLAN SELECT * FROM claim_codes WHERE expires_at < datetime('now')", 604 609 ) 605 610 .fetch_all(&pool) 606 611 .await ··· 612 617 .collect::<Vec<_>>() 613 618 .join("\n"); 614 619 assert!( 615 - detail.contains("idx_claim_codes_did"), 616 - "claim_codes WHERE did query must use idx_claim_codes_did; got: {detail}" 620 + detail.contains("idx_claim_codes_expires_at"), 621 + "claim_codes WHERE expires_at query must use idx_claim_codes_expires_at; got: {detail}" 617 622 ); 618 623 } 619 624
+478
crates/relay/src/routes/claim_codes.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: Bearer token from Authorization header, JSON request body, config, DB pool 4 + // Processes: auth check → input validation → code generation → DB batch insert (transaction) 5 + // Returns: JSON { codes: [...] } on success; ApiError on all failure paths 6 + 7 + use axum::{extract::State, http::HeaderMap, response::Json}; 8 + use rand_core::{OsRng, RngCore}; 9 + use serde::{Deserialize, Serialize}; 10 + use subtle::ConstantTimeEq; 11 + 12 + use common::{ApiError, ErrorCode}; 13 + 14 + use crate::app::AppState; 15 + 16 + const MAX_COUNT: u32 = 10; 17 + const CODE_LEN: usize = 6; 18 + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 19 + 20 + fn default_expires_in_hours() -> u32 { 21 + 24 22 + } 23 + 24 + #[derive(Deserialize)] 25 + #[serde(rename_all = "camelCase")] 26 + pub struct ClaimCodesRequest { 27 + count: u32, 28 + #[serde(default = "default_expires_in_hours")] 29 + expires_in_hours: u32, 30 + } 31 + 32 + #[derive(Serialize)] 33 + pub struct ClaimCodesResponse { 34 + codes: Vec<String>, 35 + } 36 + 37 + pub async fn claim_codes( 38 + State(state): State<AppState>, 39 + headers: HeaderMap, 40 + Json(payload): Json<ClaimCodesRequest>, 41 + ) -> Result<Json<ClaimCodesResponse>, ApiError> { 42 + // --- Auth: require matching Bearer token --- 43 + // Check this first so unauthenticated callers cannot probe server configuration. 44 + let expected_token = state 45 + .config 46 + .admin_token 47 + .as_deref() 48 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "admin token not configured"))?; 49 + 50 + let auth_value = headers 51 + .get(axum::http::header::AUTHORIZATION) 52 + .and_then(|v| v.to_str().ok()) 53 + .unwrap_or(""); 54 + 55 + let provided_token = auth_value.strip_prefix("Bearer ").ok_or_else(|| { 56 + ApiError::new( 57 + ErrorCode::Unauthorized, 58 + "missing or invalid Authorization header", 59 + ) 60 + })?; 61 + 62 + if provided_token 63 + .as_bytes() 64 + .ct_eq(expected_token.as_bytes()) 65 + .unwrap_u8() 66 + != 1 67 + { 68 + return Err(ApiError::new( 69 + ErrorCode::Unauthorized, 70 + "invalid admin token", 71 + )); 72 + } 73 + 74 + // --- Validate input --- 75 + if payload.count == 0 || payload.count > MAX_COUNT { 76 + return Err(ApiError::new( 77 + ErrorCode::InvalidClaim, 78 + format!("count must be between 1 and {MAX_COUNT}"), 79 + )); 80 + } 81 + if payload.expires_in_hours == 0 { 82 + return Err(ApiError::new( 83 + ErrorCode::InvalidClaim, 84 + "expiresInHours must be greater than 0", 85 + )); 86 + } 87 + 88 + // --- Generate unique codes and insert in a single transaction --- 89 + // Retry up to 3 times on the rare event of a uniqueness conflict with an 90 + // existing DB row (probability ≈ existing_codes / 36^6 per code generated). 91 + for attempt in 0..3_usize { 92 + let codes = generate_unique_codes(payload.count as usize); 93 + match insert_claim_codes(&state.db, &codes, payload.expires_in_hours).await { 94 + Ok(()) => return Ok(Json(ClaimCodesResponse { codes })), 95 + Err(e) if is_unique_violation(&e) && attempt < 2 => { 96 + tracing::warn!(attempt, "claim code uniqueness conflict; retrying"); 97 + continue; 98 + } 99 + Err(e) => { 100 + tracing::error!(error = %e, "failed to insert claim codes"); 101 + return Err(ApiError::new( 102 + ErrorCode::InternalError, 103 + "failed to store claim codes", 104 + )); 105 + } 106 + } 107 + } 108 + 109 + Err(ApiError::new( 110 + ErrorCode::InternalError, 111 + "failed to generate unique claim codes after retries", 112 + )) 113 + } 114 + 115 + /// Generate `count` unique codes, ensuring no duplicates within the batch. 116 + fn generate_unique_codes(count: usize) -> Vec<String> { 117 + let mut codes = std::collections::HashSet::with_capacity(count); 118 + while codes.len() < count { 119 + codes.insert(generate_code()); 120 + } 121 + codes.into_iter().collect() 122 + } 123 + 124 + /// Generate a single 6-character uppercase alphanumeric code. 125 + fn generate_code() -> String { 126 + let mut buf = [0u8; CODE_LEN]; 127 + OsRng.fill_bytes(&mut buf); 128 + buf.iter() 129 + .map(|&b| CHARSET[(b as usize) % CHARSET.len()] as char) 130 + .collect() 131 + } 132 + 133 + /// Insert all codes in a single transaction; returns Err if any INSERT fails. 134 + async fn insert_claim_codes( 135 + db: &sqlx::SqlitePool, 136 + codes: &[String], 137 + expires_in_hours: u32, 138 + ) -> Result<(), sqlx::Error> { 139 + let offset = format!("+{expires_in_hours} hours"); 140 + let mut tx = db.begin().await?; 141 + for code in codes { 142 + sqlx::query( 143 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 144 + VALUES (?, datetime('now', ?), datetime('now'))", 145 + ) 146 + .bind(code) 147 + .bind(&offset) 148 + .execute(&mut *tx) 149 + .await?; 150 + } 151 + tx.commit().await?; 152 + Ok(()) 153 + } 154 + 155 + fn is_unique_violation(e: &sqlx::Error) -> bool { 156 + matches!( 157 + e, 158 + sqlx::Error::Database(db_err) 159 + if db_err.kind() == sqlx::error::ErrorKind::UniqueViolation 160 + ) 161 + } 162 + 163 + #[cfg(test)] 164 + mod tests { 165 + use std::sync::Arc; 166 + 167 + use axum::{ 168 + body::Body, 169 + http::{Request, StatusCode}, 170 + }; 171 + use tower::ServiceExt; 172 + 173 + use crate::app::{app, test_state, AppState}; 174 + 175 + // ── Helpers ────────────────────────────────────────────────────────────── 176 + 177 + async fn test_state_with_admin_token() -> AppState { 178 + let base = test_state().await; 179 + let mut config = (*base.config).clone(); 180 + config.admin_token = Some("test-admin-token".to_string()); 181 + AppState { 182 + config: Arc::new(config), 183 + db: base.db, 184 + } 185 + } 186 + 187 + fn post_claim_codes(body: &str, bearer: Option<&str>) -> Request<Body> { 188 + let mut builder = Request::builder() 189 + .method("POST") 190 + .uri("/v1/accounts/claim-codes") 191 + .header("Content-Type", "application/json"); 192 + if let Some(token) = bearer { 193 + builder = builder.header("Authorization", format!("Bearer {token}")); 194 + } 195 + builder.body(Body::from(body.to_string())).unwrap() 196 + } 197 + 198 + // ── Happy path ──────────────────────────────────────────────────────────── 199 + 200 + #[tokio::test] 201 + async fn returns_200_with_one_code() { 202 + // MM-86.AC1.1 203 + let response = app(test_state_with_admin_token().await) 204 + .oneshot(post_claim_codes( 205 + r#"{"count": 1, "expiresInHours": 24}"#, 206 + Some("test-admin-token"), 207 + )) 208 + .await 209 + .unwrap(); 210 + 211 + assert_eq!(response.status(), StatusCode::OK); 212 + let body = axum::body::to_bytes(response.into_body(), 4096) 213 + .await 214 + .unwrap(); 215 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 216 + let codes = json["codes"].as_array().unwrap(); 217 + assert_eq!(codes.len(), 1); 218 + } 219 + 220 + #[tokio::test] 221 + async fn returns_ten_codes_for_batch() { 222 + // MM-86.AC1.2 223 + let response = app(test_state_with_admin_token().await) 224 + .oneshot(post_claim_codes( 225 + r#"{"count": 10, "expiresInHours": 24}"#, 226 + Some("test-admin-token"), 227 + )) 228 + .await 229 + .unwrap(); 230 + 231 + assert_eq!(response.status(), StatusCode::OK); 232 + let body = axum::body::to_bytes(response.into_body(), 4096) 233 + .await 234 + .unwrap(); 235 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 236 + assert_eq!(json["codes"].as_array().unwrap().len(), 10); 237 + } 238 + 239 + #[tokio::test] 240 + async fn defaults_expires_in_hours_to_24() { 241 + // MM-86.AC1.3: expiresInHours is optional; default = 24h 242 + let state = test_state_with_admin_token().await; 243 + let db = state.db.clone(); 244 + 245 + let response = app(state) 246 + .oneshot(post_claim_codes( 247 + r#"{"count": 1}"#, 248 + Some("test-admin-token"), 249 + )) 250 + .await 251 + .unwrap(); 252 + 253 + assert_eq!(response.status(), StatusCode::OK); 254 + let body = axum::body::to_bytes(response.into_body(), 4096) 255 + .await 256 + .unwrap(); 257 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 258 + let code = json["codes"][0].as_str().unwrap(); 259 + 260 + // expires_at should be roughly 24h from now; verify it is in the future. 261 + let expires_at: String = 262 + sqlx::query_scalar("SELECT expires_at FROM claim_codes WHERE code = ?") 263 + .bind(code) 264 + .fetch_one(&db) 265 + .await 266 + .unwrap(); 267 + 268 + // SQLite datetime('now', '+24 hours') produces a value > datetime('now'). 269 + let is_future: bool = sqlx::query_scalar("SELECT ? > datetime('now')") 270 + .bind(&expires_at) 271 + .fetch_one(&db) 272 + .await 273 + .unwrap(); 274 + assert!(is_future, "expires_at must be in the future"); 275 + } 276 + 277 + // ── Code format ─────────────────────────────────────────────────────────── 278 + 279 + #[tokio::test] 280 + async fn codes_are_6_char_uppercase_alphanumeric() { 281 + // MM-86.AC2.1 282 + let response = app(test_state_with_admin_token().await) 283 + .oneshot(post_claim_codes( 284 + r#"{"count": 5, "expiresInHours": 1}"#, 285 + Some("test-admin-token"), 286 + )) 287 + .await 288 + .unwrap(); 289 + 290 + let body = axum::body::to_bytes(response.into_body(), 4096) 291 + .await 292 + .unwrap(); 293 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 294 + for code in json["codes"].as_array().unwrap() { 295 + let s = code.as_str().unwrap(); 296 + assert_eq!(s.len(), 6, "code must be 6 chars, got: {s}"); 297 + assert!( 298 + s.chars() 299 + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()), 300 + "code must be uppercase alphanumeric, got: {s}" 301 + ); 302 + } 303 + } 304 + 305 + #[tokio::test] 306 + async fn codes_in_batch_are_unique() { 307 + // MM-86.AC2.2 308 + let response = app(test_state_with_admin_token().await) 309 + .oneshot(post_claim_codes( 310 + r#"{"count": 10, "expiresInHours": 1}"#, 311 + Some("test-admin-token"), 312 + )) 313 + .await 314 + .unwrap(); 315 + 316 + let body = axum::body::to_bytes(response.into_body(), 4096) 317 + .await 318 + .unwrap(); 319 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 320 + let codes: Vec<&str> = json["codes"] 321 + .as_array() 322 + .unwrap() 323 + .iter() 324 + .map(|v| v.as_str().unwrap()) 325 + .collect(); 326 + let unique: std::collections::HashSet<&&str> = codes.iter().collect(); 327 + assert_eq!( 328 + unique.len(), 329 + codes.len(), 330 + "codes within a batch must be unique" 331 + ); 332 + } 333 + 334 + // ── DB persistence ──────────────────────────────────────────────────────── 335 + 336 + #[tokio::test] 337 + async fn codes_persisted_in_db_with_pending_status() { 338 + // MM-86.AC3.1: stored with redeemed_at NULL (pending) 339 + let state = test_state_with_admin_token().await; 340 + let db = state.db.clone(); 341 + 342 + let response = app(state) 343 + .oneshot(post_claim_codes( 344 + r#"{"count": 2, "expiresInHours": 48}"#, 345 + Some("test-admin-token"), 346 + )) 347 + .await 348 + .unwrap(); 349 + 350 + assert_eq!(response.status(), StatusCode::OK); 351 + let body = axum::body::to_bytes(response.into_body(), 4096) 352 + .await 353 + .unwrap(); 354 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 355 + 356 + for code in json["codes"].as_array().unwrap() { 357 + let code_str = code.as_str().unwrap(); 358 + let row: (String, Option<String>) = 359 + sqlx::query_as("SELECT expires_at, redeemed_at FROM claim_codes WHERE code = ?") 360 + .bind(code_str) 361 + .fetch_one(&db) 362 + .await 363 + .expect("code must exist in DB"); 364 + 365 + assert!( 366 + row.1.is_none(), 367 + "redeemed_at must be NULL for a freshly generated code" 368 + ); 369 + } 370 + } 371 + 372 + // ── Input validation ────────────────────────────────────────────────────── 373 + 374 + #[tokio::test] 375 + async fn count_zero_returns_400() { 376 + // MM-86.AC4.1 377 + let response = app(test_state_with_admin_token().await) 378 + .oneshot(post_claim_codes( 379 + r#"{"count": 0, "expiresInHours": 24}"#, 380 + Some("test-admin-token"), 381 + )) 382 + .await 383 + .unwrap(); 384 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 385 + } 386 + 387 + #[tokio::test] 388 + async fn count_eleven_returns_400() { 389 + // MM-86.AC4.2 390 + let response = app(test_state_with_admin_token().await) 391 + .oneshot(post_claim_codes( 392 + r#"{"count": 11, "expiresInHours": 24}"#, 393 + Some("test-admin-token"), 394 + )) 395 + .await 396 + .unwrap(); 397 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 398 + } 399 + 400 + #[tokio::test] 401 + async fn expires_in_hours_zero_returns_400() { 402 + // MM-86.AC4.3 403 + let response = app(test_state_with_admin_token().await) 404 + .oneshot(post_claim_codes( 405 + r#"{"count": 1, "expiresInHours": 0}"#, 406 + Some("test-admin-token"), 407 + )) 408 + .await 409 + .unwrap(); 410 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 411 + } 412 + 413 + #[tokio::test] 414 + async fn missing_count_returns_422() { 415 + // MM-86.AC4.4: serde rejects missing required field 416 + let response = app(test_state_with_admin_token().await) 417 + .oneshot(post_claim_codes( 418 + r#"{"expiresInHours": 24}"#, 419 + Some("test-admin-token"), 420 + )) 421 + .await 422 + .unwrap(); 423 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 424 + } 425 + 426 + // ── Auth ────────────────────────────────────────────────────────────────── 427 + 428 + #[tokio::test] 429 + async fn missing_authorization_header_returns_401() { 430 + // MM-86.AC5.1 431 + let response = app(test_state_with_admin_token().await) 432 + .oneshot(post_claim_codes(r#"{"count": 1}"#, None)) 433 + .await 434 + .unwrap(); 435 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 436 + } 437 + 438 + #[tokio::test] 439 + async fn wrong_bearer_token_returns_401() { 440 + // MM-86.AC5.2 441 + let response = app(test_state_with_admin_token().await) 442 + .oneshot(post_claim_codes(r#"{"count": 1}"#, Some("wrong-token"))) 443 + .await 444 + .unwrap(); 445 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 446 + } 447 + 448 + #[tokio::test] 449 + async fn bare_token_without_bearer_prefix_returns_401() { 450 + // MM-86.AC5.3 451 + let request = Request::builder() 452 + .method("POST") 453 + .uri("/v1/accounts/claim-codes") 454 + .header("Content-Type", "application/json") 455 + .header("Authorization", "test-admin-token") // no "Bearer " prefix 456 + .body(Body::from(r#"{"count": 1}"#)) 457 + .unwrap(); 458 + 459 + let response = app(test_state_with_admin_token().await) 460 + .oneshot(request) 461 + .await 462 + .unwrap(); 463 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 464 + } 465 + 466 + #[tokio::test] 467 + async fn admin_token_not_configured_returns_401() { 468 + // MM-86.AC5.4: test_state() leaves admin_token as None 469 + let response = app(test_state().await) 470 + .oneshot(post_claim_codes( 471 + r#"{"count": 1}"#, 472 + Some("test-admin-token"), 473 + )) 474 + .await 475 + .unwrap(); 476 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 477 + } 478 + }
+1
crates/relay/src/routes/mod.rs
··· 1 + pub mod claim_codes; 1 2 pub mod create_signing_key; 2 3 pub mod describe_server; 3 4 pub mod health;