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(MM-94): implement POST /v1/handles with DnsProvider trait and session auth

- Add POST /v1/handles handler (create_handle.rs): validates handle format
(<name>.<available_domain>), enforces uniqueness (409), optionally calls
DnsProvider if configured, inserts into handles table, returns
{ handle, dns_status, did }

- Add DnsProvider trait (dns.rs): object-safe async abstraction using
Pin<Box<dyn Future>>; AppState carries Option<Arc<dyn DnsProvider>>,
always None for v0.1 (MM-142 wires in Cloudflare/Route53)

- Add require_session + SessionInfo to auth.rs: mirrors require_pending_session
but queries sessions table by token_hash for promoted-account Bearer auth

- V009 migration (sessions_v2): rebuilds sessions table with nullable device_id
(devices are deleted at DID promotion) and adds token_hash UNIQUE column

- Modify POST /v1/dids (MM-90): remove shortcut handle insertion; add session
token generation and INSERT sessions in promotion transaction; add session_token
to response so client can immediately call POST /v1/handles

- Add HandleAlreadyExists (409) and DnsError (502) error codes to common

- Add bruno/create_handle.bru (seq 9) for manual API testing

authored by

Malpercio and committed by
Tangled
98933081 e2456f18

+713 -20
+22
bruno/create_handle.bru
··· 1 + meta { 2 + name: Create Handle 3 + type: http 4 + seq: 9 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/v1/handles 9 + body: json 10 + auth: bearer 11 + } 12 + 13 + auth:bearer { 14 + token: {{sessionToken}} 15 + } 16 + 17 + body:json { 18 + { 19 + "accountId": "{{did}}", 20 + "handle": "{{handle}}" 21 + } 22 + }
+6
crates/common/src/error.rs
··· 42 42 DidAlreadyExists, 43 43 /// The external PLC directory returned a non-success response. 44 44 PlcDirectoryError, 45 + /// A configured DNS provider returned an error when creating a subdomain record. 46 + DnsError, 47 + /// A handle submitted for registration is already claimed. 48 + HandleAlreadyExists, 45 49 // TODO: add remaining codes from Appendix A as endpoints are implemented: 46 50 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION 47 51 // 401: INVALID_CREDENTIALS ··· 75 79 ErrorCode::ClaimCodeRedeemed => 409, 76 80 ErrorCode::DidAlreadyExists => 409, 77 81 ErrorCode::PlcDirectoryError => 502, 82 + ErrorCode::DnsError => 502, 83 + ErrorCode::HandleAlreadyExists => 409, 78 84 } 79 85 } 80 86 }
+8
crates/relay/src/app.rs
··· 12 12 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 13 13 use tracing_opentelemetry::OpenTelemetrySpanExt; 14 14 15 + use crate::dns::DnsProvider; 15 16 use crate::routes::claim_codes::claim_codes; 16 17 use crate::routes::create_account::create_account; 17 18 use crate::routes::create_did::create_did_handler; 19 + use crate::routes::create_handle::create_handle_handler; 18 20 use crate::routes::create_mobile_account::create_mobile_account; 19 21 use crate::routes::create_signing_key::create_signing_key; 20 22 use crate::routes::describe_server::describe_server; ··· 79 81 pub config: Arc<Config>, 80 82 pub db: sqlx::SqlitePool, 81 83 pub http_client: Client, 84 + /// Optional DNS provider for subdomain record creation on handle registration. 85 + /// `None` in v0.1 — operators manage DNS records manually. 86 + /// Wired in by MM-142 (DNS provider integration). 87 + pub dns_provider: Option<Arc<dyn DnsProvider>>, 82 88 } 83 89 84 90 /// Build the Axum router with middleware and routes. ··· 98 104 .route("/v1/accounts/mobile", post(create_mobile_account)) 99 105 .route("/v1/devices", post(register_device)) 100 106 .route("/v1/dids", post(create_did_handler)) 107 + .route("/v1/handles", post(create_handle_handler)) 101 108 .route("/v1/relay/keys", post(create_signing_key)) 102 109 .layer(CorsLayer::permissive()) 103 110 .layer(TraceLayer::new_for_http().make_span_with(OtelMakeSpan)) ··· 157 164 }), 158 165 db, 159 166 http_client, 167 + dns_provider: None, 160 168 } 161 169 } 162 170
+2
crates/relay/src/db/CLAUDE.md
··· 3 3 Last verified: 2026-03-13 4 4 5 5 ## Latest Updates 6 + - **V009**: Rebuilt sessions with nullable device_id (devices are deleted at DID promotion) and added token_hash UNIQUE column for Bearer token authentication (same SHA-256 hex pattern as pending_sessions) 6 7 - **V008**: Rebuilt accounts with nullable password_hash (mobile accounts have no password); added pending_did column to pending_accounts for DID pre-store retry resilience 7 8 8 9 ## Purpose ··· 41 42 - `migrations/V006__devices_v2.sql` - Rebuilds devices: replaces did FK (accounts) with account_id FK (pending_accounts); adds platform, public_key, device_token_hash; also rebuilds sessions, oauth_tokens, refresh_tokens (cascade due to FK references) 42 43 - `migrations/V007__pending_sessions.sql` - pending_sessions table: id, account_id (FK→pending_accounts), device_id (FK→devices), token_hash (UNIQUE), created_at, expires_at; used by POST /v1/accounts/mobile to issue a pre-DID session for the DID-creation step 43 44 - `migrations/V008__did_promotion.sql` - Rebuilds accounts with nullable password_hash (mobile accounts have no password); adds pending_did column to pending_accounts for DID pre-store retry resilience 45 + - `migrations/V009__sessions_v2.sql` - Rebuilds sessions: makes device_id nullable (devices are transient, deleted at DID promotion) and adds token_hash UNIQUE column for Bearer token auth via require_session
+34
crates/relay/src/db/migrations/V009__sessions_v2.sql
··· 1 + -- V009: Rebuild sessions for post-promotion auth 2 + -- Applied in a single transaction by the migration runner. 3 + -- 4 + -- 1. Makes device_id nullable. 5 + -- V006 made devices transient (deleted at DID promotion), so promoted-account 6 + -- sessions cannot reference a device row. The FK is kept but nullable. 7 + -- 8 + -- 2. Adds token_hash for Bearer token authentication. 9 + -- Pattern mirrors pending_sessions: 32 random bytes → base64url token returned 10 + -- to client, SHA-256 hex stored here. Used by require_session in auth.rs. 11 + -- 12 + -- SQLite does not support ALTER COLUMN, so a full table rebuild is required. 13 + -- DROP TABLE does not check FK constraints; the refresh_tokens FK to sessions 14 + -- remains valid after the rename. 15 + 16 + CREATE TABLE sessions_new ( 17 + id TEXT NOT NULL, 18 + did TEXT NOT NULL REFERENCES accounts (did), 19 + device_id TEXT REFERENCES devices (id), -- nullable: device deleted at promotion 20 + token_hash TEXT UNIQUE, -- SHA-256 hex of raw 32-byte token 21 + created_at TEXT NOT NULL, 22 + expires_at TEXT NOT NULL, 23 + PRIMARY KEY (id) 24 + ); 25 + 26 + INSERT INTO sessions_new 27 + SELECT id, did, device_id, NULL, created_at, expires_at 28 + FROM sessions; 29 + 30 + DROP TABLE sessions; 31 + 32 + ALTER TABLE sessions_new RENAME TO sessions; 33 + 34 + CREATE INDEX idx_sessions_did ON sessions (did);
+4
crates/relay/src/db/mod.rs
··· 60 60 version: 8, 61 61 sql: include_str!("migrations/V008__did_promotion.sql"), 62 62 }, 63 + Migration { 64 + version: 9, 65 + sql: include_str!("migrations/V009__sessions_v2.sql"), 66 + }, 63 67 ]; 64 68 65 69 /// Open a WAL-mode SQLite connection pool with a maximum of 1 connection.
+31
crates/relay/src/dns.rs
··· 1 + // DNS provider abstraction for subdomain record management. 2 + // 3 + // Implementations create DNS records when handles are registered (POST /v1/handles). 4 + // For v0.1, AppState carries `dns_provider: None` and no records are created 5 + // automatically — operators manage DNS manually. 6 + // 7 + // MM-142 wires in real provider implementations (Cloudflare, Route53). 8 + 9 + use std::future::Future; 10 + use std::pin::Pin; 11 + 12 + /// Error returned by a [`DnsProvider`] operation. 13 + #[derive(Debug, thiserror::Error)] 14 + #[error("DNS provider error: {0}")] 15 + pub struct DnsError(pub String); 16 + 17 + /// Abstraction over DNS record management. 18 + /// 19 + /// Object-safe: uses `Pin<Box<dyn Future>>` so `dyn DnsProvider` works with `Arc`. 20 + pub trait DnsProvider: Send + Sync { 21 + /// Create a DNS record pointing `name` (a subdomain label, e.g. `"alice"`) to 22 + /// `target` (an IP address or hostname the relay is reachable at). 23 + /// 24 + /// The provider is responsible for constructing the full qualified name from 25 + /// `name` and its configured zone. 26 + fn create_record<'a>( 27 + &'a self, 28 + name: &'a str, 29 + target: &'a str, 30 + ) -> Pin<Box<dyn Future<Output = Result<(), DnsError>> + Send + 'a>>; 31 + }
+2
crates/relay/src/main.rs
··· 5 5 6 6 mod app; 7 7 mod db; 8 + mod dns; 8 9 mod routes; 9 10 mod telemetry; 10 11 ··· 106 107 config: Arc::new(config), 107 108 db: pool, 108 109 http_client, 110 + dns_provider: None, 109 111 }; 110 112 111 113 let listener = tokio::net::TcpListener::bind(&addr)
+70
crates/relay/src/routes/auth.rs
··· 13 13 pub device_id: String, 14 14 } 15 15 16 + /// Information about an authenticated promoted-account session. 17 + #[derive(Debug)] 18 + pub struct SessionInfo { 19 + pub did: String, 20 + } 21 + 16 22 /// Validate the admin Bearer token from request headers. 17 23 /// 18 24 /// Returns `Ok(())` when the token is present, has the `"Bearer "` prefix, and the ··· 131 137 }) 132 138 } 133 139 140 + /// Authenticate a promoted-account Bearer token. 141 + /// 142 + /// Extracts the Bearer token from the Authorization header, SHA-256 hashes the raw 143 + /// decoded bytes (matching the storage format written by `POST /v1/dids`), and 144 + /// queries `sessions` for a matching, unexpired row. 145 + /// 146 + /// # Errors 147 + /// Returns `ApiError::Unauthorized` if: 148 + /// - The Authorization header is missing 149 + /// - The token is not valid base64url 150 + /// - No unexpired session matches the token hash 151 + pub async fn require_session( 152 + headers: &HeaderMap, 153 + db: &sqlx::SqlitePool, 154 + ) -> Result<SessionInfo, ApiError> { 155 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 156 + use sha2::{Digest, Sha256}; 157 + 158 + let token = headers 159 + .get(axum::http::header::AUTHORIZATION) 160 + .and_then(|v| { 161 + v.to_str() 162 + .inspect_err(|_| { 163 + tracing::warn!( 164 + "Authorization header contains non-UTF-8 bytes; treating as absent" 165 + ); 166 + }) 167 + .ok() 168 + }) 169 + .and_then(|v| v.strip_prefix("Bearer ")) 170 + .ok_or_else(|| { 171 + ApiError::new( 172 + ErrorCode::Unauthorized, 173 + "missing or invalid Authorization header", 174 + ) 175 + })?; 176 + 177 + let token_bytes = URL_SAFE_NO_PAD 178 + .decode(token) 179 + .map_err(|_| ApiError::new(ErrorCode::Unauthorized, "invalid session token"))?; 180 + let token_hash: String = Sha256::digest(&token_bytes) 181 + .iter() 182 + .map(|b| format!("{b:02x}")) 183 + .collect(); 184 + 185 + let row: Option<(String,)> = sqlx::query_as( 186 + "SELECT did FROM sessions WHERE token_hash = ? AND expires_at > datetime('now')", 187 + ) 188 + .bind(&token_hash) 189 + .fetch_optional(db) 190 + .await 191 + .map_err(|e| { 192 + tracing::error!(error = %e, "failed to query session"); 193 + ApiError::new(ErrorCode::InternalError, "session lookup failed") 194 + })?; 195 + 196 + let (did,) = row.ok_or_else(|| { 197 + ApiError::new(ErrorCode::Unauthorized, "invalid or expired session token") 198 + })?; 199 + 200 + Ok(SessionInfo { did }) 201 + } 202 + 134 203 #[cfg(test)] 135 204 mod tests { 136 205 use super::*; ··· 147 216 config: Arc::new(config), 148 217 db: base.db, 149 218 http_client: base.http_client, 219 + dns_provider: base.dns_provider, 150 220 } 151 221 } 152 222
+72 -20
crates/relay/src/routes/create_did.rs
··· 24 24 // 8. SELECT EXISTS(SELECT 1 FROM accounts WHERE did = verified.did) → 409 if true 25 25 // 9. If !skip_plc_directory: POST {plc_directory_url}/{did} with signed_op_str 26 26 // 10. build_did_document(&verified) → serde_json::Value 27 - // 11. Atomic transaction: 27 + // 11. Generate session token: 32 random bytes → base64url (returned) + SHA-256 hex (stored) 28 + // 12. Atomic transaction: 28 29 // INSERT accounts (did, email, password_hash=NULL) 29 30 // INSERT did_documents (did, document) 30 - // INSERT handles (handle, did) 31 + // INSERT sessions (id, did, device_id=NULL, token_hash, expires_at=+1 year) 31 32 // DELETE pending_sessions WHERE account_id = ? 32 33 // DELETE devices WHERE account_id = ? 33 34 // DELETE pending_accounts WHERE id = ? 34 - // 12. Return { "did": "did:plc:...", "did_document": {...}, "status": "active" } 35 + // 13. Return { "did": "did:plc:...", "did_document": {...}, "status": "active", "session_token": "..." } 36 + // 37 + // Note: handles are NOT inserted here. Handle creation is the caller's responsibility 38 + // via POST /v1/handles (MM-94), which validates format and optionally creates DNS records. 35 39 // 36 - // Outputs (success): 200 { "did": "did:plc:...", "did_document": {...}, "status": "active" } 40 + // Outputs (success): 200 { "did": "...", "did_document": {...}, "status": "active", "session_token": "..." } 37 41 // Outputs (error): 400 INVALID_CLAIM, 401 UNAUTHORIZED, 409 DID_ALREADY_EXISTS, 38 42 // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 39 43 40 44 use axum::{extract::State, http::HeaderMap, Json}; 45 + use rand_core::RngCore; 41 46 use serde::{Deserialize, Serialize}; 42 47 43 48 use crate::app::AppState; ··· 65 70 pub did: String, 66 71 pub did_document: serde_json::Value, 67 72 pub status: &'static str, 73 + pub session_token: String, 68 74 } 69 75 70 76 pub async fn create_did_handler( ··· 238 244 ApiError::new(ErrorCode::InternalError, "failed to serialize DID document") 239 245 })?; 240 246 241 - // Step 11: Atomically promote the account. 247 + // Step 11: Generate session token before entering the transaction so it can be 248 + // returned in the response regardless of which transaction path commits. 249 + let mut token_bytes = [0u8; 32]; 250 + rand_core::OsRng.fill_bytes(&mut token_bytes); 251 + let session_token = { 252 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 253 + URL_SAFE_NO_PAD.encode(token_bytes) 254 + }; 255 + let token_hash: String = { 256 + use sha2::{Digest, Sha256}; 257 + Sha256::digest(token_bytes) 258 + .iter() 259 + .map(|b| format!("{b:02x}")) 260 + .collect() 261 + }; 262 + let session_id = uuid::Uuid::new_v4().to_string(); 263 + 264 + // Step 12: Atomically promote the account. 242 265 let mut tx = state 243 266 .db 244 267 .begin() ··· 274 297 .inspect_err(|e| tracing::error!(error = %e, "failed to insert did_document")) 275 298 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to store DID document"))?; 276 299 277 - sqlx::query("INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))") 278 - .bind(&handle) 279 - .bind(did) 280 - .execute(&mut *tx) 281 - .await 282 - .inspect_err(|e| tracing::error!(error = %e, "failed to insert handle")) 283 - .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register handle"))?; 300 + sqlx::query( 301 + "INSERT INTO sessions (id, did, device_id, token_hash, created_at, expires_at) \ 302 + VALUES (?, ?, NULL, ?, datetime('now'), datetime('now', '+1 year'))", 303 + ) 304 + .bind(&session_id) 305 + .bind(did) 306 + .bind(&token_hash) 307 + .execute(&mut *tx) 308 + .await 309 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert session")) 310 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create session"))?; 284 311 285 312 sqlx::query("DELETE FROM pending_sessions WHERE account_id = ?") 286 313 .bind(&session.account_id) ··· 308 335 .inspect_err(|e| tracing::error!(error = %e, "failed to commit promotion transaction")) 309 336 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to commit transaction"))?; 310 337 311 - // Step 12: Return the result. 338 + // Step 13: Return the result. 312 339 Ok(Json(CreateDidResponse { 313 340 did: did.clone(), 314 341 did_document, 315 342 status: "active", 343 + session_token, 316 344 })) 317 345 } 318 346 ··· 545 573 .await 546 574 .unwrap(); 547 575 548 - // AC2.1: 200 OK with { did, did_document, status: "active" } 576 + // AC2.1: 200 OK with { did, did_document, status: "active", session_token } 549 577 assert_eq!(response.status(), StatusCode::OK, "expected 200 OK"); 550 578 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 551 579 .await ··· 562 590 assert!( 563 591 body["did_document"].is_object(), 564 592 "did_document should be a JSON object" 593 + ); 594 + assert!( 595 + body["session_token"].as_str().map(|s| !s.is_empty()).unwrap_or(false), 596 + "response should include a non-empty session_token" 565 597 ); 566 598 567 599 let did = body["did"].as_str().unwrap(); ··· 619 651 let (document,) = doc_row.expect("did_documents row should exist"); 620 652 assert!(!document.is_empty(), "document should be non-empty"); 621 653 622 - // AC2.4: handles row links handle to did 623 - let handle_row: Option<(String,)> = 624 - sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 625 - .bind(&setup.handle) 654 + // AC2.4: session row created with correct did and matching token_hash 655 + let session_token_str = body["session_token"].as_str().unwrap(); 656 + let token_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD 657 + .decode(session_token_str) 658 + .expect("session_token should be valid base64url"); 659 + let expected_hash: String = { 660 + use sha2::{Digest, Sha256}; 661 + Sha256::digest(&token_bytes) 662 + .iter() 663 + .map(|b| format!("{b:02x}")) 664 + .collect() 665 + }; 666 + let session_row: Option<(String,)> = 667 + sqlx::query_as("SELECT did FROM sessions WHERE token_hash = ?") 668 + .bind(&expected_hash) 626 669 .fetch_optional(&db) 627 670 .await 628 671 .unwrap(); 629 - let (handle_did,) = handle_row.expect("handles row should exist"); 630 - assert_eq!(handle_did, did, "handles.did should match response did"); 672 + let (session_did,) = session_row.expect("sessions row should exist for token_hash"); 673 + assert_eq!(session_did, did, "sessions.did should match response did"); 674 + 675 + // AC2.4b: handles table should NOT have a row yet (handle created via POST /v1/handles) 676 + let handle_count: i64 = 677 + sqlx::query_scalar("SELECT COUNT(*) FROM handles WHERE did = ?") 678 + .bind(did) 679 + .fetch_one(&db) 680 + .await 681 + .unwrap(); 682 + assert_eq!(handle_count, 0, "handles table should be empty after DID ceremony"); 631 683 632 684 // AC2.5: pending_accounts and pending_sessions deleted 633 685 let pending_count: i64 =
+457
crates/relay/src/routes/create_handle.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // POST /v1/handles — Initial handle creation for a provisioned account 4 + // 5 + // Inputs: 6 + // - Authorization: Bearer <session_token> 7 + // - JSON body: { 8 + // "account_id": "did:plc:...", 9 + // "handle": "alice.example.com" 10 + // } 11 + // 12 + // Processing steps: 13 + // 1. require_session → SessionInfo { did } 14 + // 2. Validate account_id matches session did (prevents acting on other accounts) 15 + // 3. validate_handle(handle, available_user_domains) → 400 INVALID_HANDLE on failure 16 + // 4. SELECT EXISTS(SELECT 1 FROM handles WHERE handle = ?) → 409 HANDLE_ALREADY_EXISTS 17 + // 5. If state.dns_provider is Some: call create_record(name, target); dns_status = "propagating" 18 + // If state.dns_provider is None: dns_status = "not_configured" 19 + // 6. INSERT INTO handles (handle, did, created_at) 20 + // 7. Return { "handle": "...", "dns_status": "...", "did": "..." } 21 + // 22 + // Outputs (success): 200 { "handle": "...", "dns_status": "not_configured"|"propagating", "did": "..." } 23 + // Outputs (error): 400 INVALID_HANDLE, 401 UNAUTHORIZED, 409 HANDLE_ALREADY_EXISTS, 24 + // 500 INTERNAL_ERROR 25 + 26 + use axum::{extract::State, http::HeaderMap, Json}; 27 + use serde::{Deserialize, Serialize}; 28 + 29 + use crate::app::AppState; 30 + use crate::routes::auth::require_session; 31 + use common::{ApiError, ErrorCode}; 32 + 33 + #[derive(Deserialize)] 34 + #[serde(rename_all = "camelCase")] 35 + pub struct CreateHandleRequest { 36 + pub account_id: String, 37 + pub handle: String, 38 + } 39 + 40 + #[derive(Serialize)] 41 + pub struct CreateHandleResponse { 42 + pub handle: String, 43 + pub dns_status: &'static str, 44 + pub did: String, 45 + } 46 + 47 + pub async fn create_handle_handler( 48 + State(state): State<AppState>, 49 + headers: HeaderMap, 50 + Json(payload): Json<CreateHandleRequest>, 51 + ) -> Result<Json<CreateHandleResponse>, ApiError> { 52 + // Step 1: Authenticate via session Bearer token. 53 + let session = require_session(&headers, &state.db).await?; 54 + 55 + // Step 2: Validate account_id matches the authenticated session. 56 + if payload.account_id != session.did { 57 + return Err(ApiError::new( 58 + ErrorCode::Unauthorized, 59 + "account_id does not match authenticated session", 60 + )); 61 + } 62 + 63 + // Step 3: Validate handle format. 64 + let name = validate_handle(&payload.handle, &state.config.available_user_domains) 65 + .map_err(|msg| ApiError::new(ErrorCode::InvalidHandle, msg))?; 66 + 67 + // Step 4: Check handle uniqueness. 68 + let exists: bool = 69 + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM handles WHERE handle = ?)") 70 + .bind(&payload.handle) 71 + .fetch_one(&state.db) 72 + .await 73 + .map_err(|e| { 74 + tracing::error!(error = %e, "failed to check handle uniqueness"); 75 + ApiError::new(ErrorCode::InternalError, "database error") 76 + })?; 77 + 78 + if exists { 79 + return Err(ApiError::new( 80 + ErrorCode::HandleAlreadyExists, 81 + "handle is already taken", 82 + )); 83 + } 84 + 85 + // Step 5: Create DNS record if a provider is configured. 86 + let dns_status = if let Some(provider) = &state.dns_provider { 87 + provider 88 + .create_record(name, &state.config.public_url) 89 + .await 90 + .map_err(|e| { 91 + tracing::error!(error = %e, handle = %payload.handle, "DNS record creation failed"); 92 + ApiError::new(ErrorCode::DnsError, "failed to create DNS record") 93 + })?; 94 + "propagating" 95 + } else { 96 + "not_configured" 97 + }; 98 + 99 + // Step 6: Insert the handle. 100 + sqlx::query("INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))") 101 + .bind(&payload.handle) 102 + .bind(&session.did) 103 + .execute(&state.db) 104 + .await 105 + .map_err(|e| { 106 + tracing::error!(error = %e, "failed to insert handle"); 107 + ApiError::new(ErrorCode::InternalError, "failed to register handle") 108 + })?; 109 + 110 + // Step 7: Return the result. 111 + Ok(Json(CreateHandleResponse { 112 + handle: payload.handle, 113 + dns_status, 114 + did: session.did, 115 + })) 116 + } 117 + 118 + /// Validate a handle string against the server's available user domains. 119 + /// 120 + /// A valid handle is `<name>.<domain>` where: 121 + /// - `name` is non-empty, contains only ASCII alphanumerics and hyphens, 122 + /// and does not start or end with a hyphen. 123 + /// - `domain` is one of the server's `available_user_domains`. 124 + /// 125 + /// Returns the `name` portion on success so callers can use it for DNS record creation. 126 + /// 127 + /// # Errors 128 + /// Returns a static error message string suitable for surfacing as a 400 body. 129 + fn validate_handle<'a>( 130 + handle: &'a str, 131 + available_domains: &[String], 132 + ) -> Result<&'a str, &'static str> { 133 + let dot = handle 134 + .find('.') 135 + .ok_or("handle must be in the format <name>.<domain>")?; 136 + 137 + let name = &handle[..dot]; 138 + let domain = &handle[dot + 1..]; 139 + 140 + if name.is_empty() { 141 + return Err("handle name cannot be empty"); 142 + } 143 + if name.starts_with('-') || name.ends_with('-') { 144 + return Err("handle name cannot start or end with a hyphen"); 145 + } 146 + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { 147 + return Err("handle name may only contain letters, digits, and hyphens"); 148 + } 149 + if domain.is_empty() { 150 + return Err("handle domain cannot be empty"); 151 + } 152 + if !available_domains.iter().any(|d| d == domain) { 153 + return Err("handle domain is not served by this relay"); 154 + } 155 + 156 + Ok(name) 157 + } 158 + 159 + // ── Tests ──────────────────────────────────────────────────────────────────── 160 + 161 + #[cfg(test)] 162 + mod tests { 163 + use super::*; 164 + use crate::app::test_state; 165 + use axum::{ 166 + body::Body, 167 + http::{Request, StatusCode}, 168 + }; 169 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 170 + use rand_core::{OsRng, RngCore}; 171 + use sha2::{Digest, Sha256}; 172 + use tower::ServiceExt; 173 + use uuid::Uuid; 174 + 175 + // ── validate_handle unit tests ───────────────────────────────────────────── 176 + 177 + #[test] 178 + fn validate_handle_accepts_valid_handle() { 179 + let domains = vec!["example.com".to_string()]; 180 + assert_eq!( 181 + validate_handle("alice.example.com", &domains), 182 + Ok("alice"), 183 + "valid handle should return the name portion" 184 + ); 185 + } 186 + 187 + #[test] 188 + fn validate_handle_rejects_no_dot() { 189 + let domains = vec!["example.com".to_string()]; 190 + assert!(validate_handle("aliceexample", &domains).is_err()); 191 + } 192 + 193 + #[test] 194 + fn validate_handle_rejects_empty_name() { 195 + let domains = vec!["example.com".to_string()]; 196 + assert!(validate_handle(".example.com", &domains).is_err()); 197 + } 198 + 199 + #[test] 200 + fn validate_handle_rejects_leading_hyphen() { 201 + let domains = vec!["example.com".to_string()]; 202 + assert!(validate_handle("-alice.example.com", &domains).is_err()); 203 + } 204 + 205 + #[test] 206 + fn validate_handle_rejects_trailing_hyphen() { 207 + let domains = vec!["example.com".to_string()]; 208 + assert!(validate_handle("alice-.example.com", &domains).is_err()); 209 + } 210 + 211 + #[test] 212 + fn validate_handle_rejects_invalid_chars() { 213 + let domains = vec!["example.com".to_string()]; 214 + assert!(validate_handle("ali_ce.example.com", &domains).is_err()); 215 + assert!(validate_handle("ali ce.example.com", &domains).is_err()); 216 + } 217 + 218 + #[test] 219 + fn validate_handle_rejects_unavailable_domain() { 220 + let domains = vec!["example.com".to_string()]; 221 + assert!(validate_handle("alice.other.com", &domains).is_err()); 222 + } 223 + 224 + #[test] 225 + fn validate_handle_accepts_hyphen_in_middle_of_name() { 226 + let domains = vec!["example.com".to_string()]; 227 + assert_eq!(validate_handle("al-ice.example.com", &domains), Ok("al-ice")); 228 + } 229 + 230 + // ── Integration test helpers ─────────────────────────────────────────────── 231 + 232 + struct TestSession { 233 + did: String, 234 + session_token: String, 235 + } 236 + 237 + /// Insert a promoted account and session directly into the DB. 238 + /// 239 + /// Skips the full DID ceremony — sets up only what the create_handle handler needs. 240 + async fn insert_account_and_session(db: &sqlx::SqlitePool) -> TestSession { 241 + let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 242 + 243 + sqlx::query( 244 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 245 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 246 + ) 247 + .bind(&did) 248 + .bind(format!("{}@test.example.com", &did[8..16])) 249 + .execute(db) 250 + .await 251 + .expect("insert account"); 252 + 253 + let mut token_bytes = [0u8; 32]; 254 + OsRng.fill_bytes(&mut token_bytes); 255 + let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 256 + let token_hash: String = Sha256::digest(token_bytes) 257 + .iter() 258 + .map(|b| format!("{b:02x}")) 259 + .collect(); 260 + 261 + sqlx::query( 262 + "INSERT INTO sessions (id, did, device_id, token_hash, created_at, expires_at) \ 263 + VALUES (?, ?, NULL, ?, datetime('now'), datetime('now', '+1 year'))", 264 + ) 265 + .bind(Uuid::new_v4().to_string()) 266 + .bind(&did) 267 + .bind(&token_hash) 268 + .execute(db) 269 + .await 270 + .expect("insert session"); 271 + 272 + TestSession { did, session_token } 273 + } 274 + 275 + fn create_handle_request( 276 + session_token: &str, 277 + account_id: &str, 278 + handle: &str, 279 + ) -> Request<Body> { 280 + let body = serde_json::json!({ 281 + "accountId": account_id, 282 + "handle": handle, 283 + }); 284 + Request::builder() 285 + .method("POST") 286 + .uri("/v1/handles") 287 + .header("Authorization", format!("Bearer {session_token}")) 288 + .header("Content-Type", "application/json") 289 + .body(Body::from(body.to_string())) 290 + .unwrap() 291 + } 292 + 293 + // ── Happy path ───────────────────────────────────────────────────────────── 294 + 295 + /// Valid handle creates a handles row and returns dns_status: "not_configured". 296 + #[tokio::test] 297 + async fn happy_path_creates_handle_with_no_dns_provider() { 298 + let state = test_state().await; 299 + let db = state.db.clone(); 300 + let ts = insert_account_and_session(&db).await; 301 + let handle = format!("alice.{}", state.config.available_user_domains[0]); 302 + 303 + let app = crate::app::app(state); 304 + let response = app 305 + .oneshot(create_handle_request(&ts.session_token, &ts.did, &handle)) 306 + .await 307 + .unwrap(); 308 + 309 + assert_eq!(response.status(), StatusCode::OK); 310 + let body: serde_json::Value = serde_json::from_slice( 311 + &axum::body::to_bytes(response.into_body(), usize::MAX) 312 + .await 313 + .unwrap(), 314 + ) 315 + .unwrap(); 316 + assert_eq!(body["handle"].as_str(), Some(handle.as_str())); 317 + assert_eq!(body["dns_status"].as_str(), Some("not_configured")); 318 + assert_eq!(body["did"].as_str(), Some(ts.did.as_str())); 319 + 320 + // Verify handles row was inserted. 321 + let row: Option<(String,)> = 322 + sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 323 + .bind(&handle) 324 + .fetch_optional(&db) 325 + .await 326 + .unwrap(); 327 + let (stored_did,) = row.expect("handles row should exist"); 328 + assert_eq!(stored_did, ts.did); 329 + } 330 + 331 + // ── Duplicate handle ─────────────────────────────────────────────────────── 332 + 333 + /// Creating the same handle twice returns 409 HANDLE_ALREADY_EXISTS. 334 + #[tokio::test] 335 + async fn duplicate_handle_returns_409() { 336 + let state = test_state().await; 337 + let db = state.db.clone(); 338 + let ts = insert_account_and_session(&db).await; 339 + let handle = format!("bob.{}", state.config.available_user_domains[0]); 340 + 341 + // Pre-insert the handle (simulate it already being taken). 342 + sqlx::query("INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))") 343 + .bind(&handle) 344 + .bind(&ts.did) 345 + .execute(&db) 346 + .await 347 + .expect("pre-insert handle"); 348 + 349 + let app = crate::app::app(state); 350 + let response = app 351 + .oneshot(create_handle_request(&ts.session_token, &ts.did, &handle)) 352 + .await 353 + .unwrap(); 354 + 355 + assert_eq!(response.status(), StatusCode::CONFLICT); 356 + let body: serde_json::Value = serde_json::from_slice( 357 + &axum::body::to_bytes(response.into_body(), usize::MAX) 358 + .await 359 + .unwrap(), 360 + ) 361 + .unwrap(); 362 + assert_eq!(body["error"]["code"], "HANDLE_ALREADY_EXISTS"); 363 + } 364 + 365 + // ── Invalid handle format ────────────────────────────────────────────────── 366 + 367 + /// Handle with no dot returns 400 INVALID_HANDLE. 368 + #[tokio::test] 369 + async fn invalid_handle_format_returns_400() { 370 + let state = test_state().await; 371 + let db = state.db.clone(); 372 + let ts = insert_account_and_session(&db).await; 373 + 374 + let app = crate::app::app(state); 375 + let response = app 376 + .oneshot(create_handle_request(&ts.session_token, &ts.did, "nodothandle")) 377 + .await 378 + .unwrap(); 379 + 380 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 381 + let body: serde_json::Value = serde_json::from_slice( 382 + &axum::body::to_bytes(response.into_body(), usize::MAX) 383 + .await 384 + .unwrap(), 385 + ) 386 + .unwrap(); 387 + assert_eq!(body["error"]["code"], "INVALID_HANDLE"); 388 + } 389 + 390 + /// Handle with a domain not in available_user_domains returns 400. 391 + #[tokio::test] 392 + async fn unavailable_domain_returns_400() { 393 + let state = test_state().await; 394 + let db = state.db.clone(); 395 + let ts = insert_account_and_session(&db).await; 396 + 397 + let app = crate::app::app(state); 398 + let response = app 399 + .oneshot(create_handle_request( 400 + &ts.session_token, 401 + &ts.did, 402 + "alice.not-our-domain.com", 403 + )) 404 + .await 405 + .unwrap(); 406 + 407 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 408 + let body: serde_json::Value = serde_json::from_slice( 409 + &axum::body::to_bytes(response.into_body(), usize::MAX) 410 + .await 411 + .unwrap(), 412 + ) 413 + .unwrap(); 414 + assert_eq!(body["error"]["code"], "INVALID_HANDLE"); 415 + } 416 + 417 + // ── Auth failures ────────────────────────────────────────────────────────── 418 + 419 + /// Missing Authorization header returns 401. 420 + #[tokio::test] 421 + async fn missing_auth_returns_401() { 422 + let state = test_state().await; 423 + let db = state.db.clone(); 424 + let ts = insert_account_and_session(&db).await; 425 + let handle = format!("alice.{}", state.config.available_user_domains[0]); 426 + 427 + let request = Request::builder() 428 + .method("POST") 429 + .uri("/v1/handles") 430 + .header("Content-Type", "application/json") 431 + .body(Body::from( 432 + serde_json::json!({"accountId": ts.did, "handle": handle}).to_string(), 433 + )) 434 + .unwrap(); 435 + 436 + let app = crate::app::app(state); 437 + let response = app.oneshot(request).await.unwrap(); 438 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 439 + } 440 + 441 + /// account_id that doesn't match the session DID returns 401. 442 + #[tokio::test] 443 + async fn mismatched_account_id_returns_401() { 444 + let state = test_state().await; 445 + let db = state.db.clone(); 446 + let ts = insert_account_and_session(&db).await; 447 + let handle = format!("alice.{}", state.config.available_user_domains[0]); 448 + 449 + let app = crate::app::app(state); 450 + let response = app 451 + .oneshot(create_handle_request(&ts.session_token, "did:plc:somebodyelse", &handle)) 452 + .await 453 + .unwrap(); 454 + 455 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 456 + } 457 + }
+2
crates/relay/src/routes/create_signing_key.rs
··· 125 125 config: Arc::new(config), 126 126 db: base.db, 127 127 http_client: base.http_client, 128 + dns_provider: base.dns_provider, 128 129 } 129 130 } 130 131 ··· 385 386 config: Arc::new(config), 386 387 db: base.db, 387 388 http_client: base.http_client, 389 + dns_provider: base.dns_provider, 388 390 }; 389 391 390 392 let response = app(state)
+1
crates/relay/src/routes/describe_server.rs
··· 127 127 config: Arc::new(config), 128 128 db: base.db, 129 129 http_client: base.http_client, 130 + dns_provider: base.dns_provider, 130 131 }; 131 132 132 133 let response = app(state)
+1
crates/relay/src/routes/mod.rs
··· 2 2 pub mod claim_codes; 3 3 pub mod create_account; 4 4 pub mod create_did; 5 + pub mod create_handle; 5 6 pub mod create_mobile_account; 6 7 pub mod create_signing_key; 7 8 pub mod describe_server;
+1
crates/relay/src/routes/test_utils.rs
··· 15 15 config: Arc::new(config), 16 16 db: base.db, 17 17 http_client: base.http_client, 18 + dns_provider: base.dns_provider, 18 19 } 19 20 }